c71e782dc01aca322141bdf2289eb88445dc5010
17 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c71e782dc0 |
feat(operator): command palette, impersonation, incident, tweaks (O.8)
- CommandPalette + useCommandPalette: ⌘K opens a search-and-jump panel over real tenants/partners + fixture flags + nav + actions. Arrow keys + Enter navigate, Escape/backdrop close. Recents are intentionally omitted for now; add when there's something to recent over. - Impersonation stub: useImpersonation + ImpersonationModal + ImpersonationBanner. Modal opens from tenant detail and from the palette. Banner stays at the top of the shell until exited. No real OBO token is minted — wiring OAuth Token Exchange is tracked as a follow-up. - IncidentModal + useIncidentModal: opened from the Overview and Infrastructure incident banners, renders the mock INCIDENT data with metrics, timeline and draft composer. - TweaksPanel + useTweaks: floating bottom-right panel for theme (dark/light), density (comfy/compact), env badge (prod/staging/dev). Saved to localStorage. - Theme/density apply via [data-theme] + [data-density] overrides in tokens.css. Topbar env badge now reads from useTweaks instead of a prop. - Layout wires ⌘K + ⌘[ at the document level and mounts the palette + modals + banner + tweaks panel once for all pages. |
||
|
|
e0ac643e80 |
feat(operator): visual-only screens with real-data overview (O.7)
- Overview (pages/index.vue): KPIs from real /tenants /partners /users, status meter, recent + needs-follow-up tables. Mock activity stream and incident banner overlay come from data/fixtures.ts. - Operator team: real GET /users filtered to platformAdmin === true, with last-seen + tenant counts. - Users (global): real read with All/Admins/Inactive views and search. - Infrastructure / Feature flags / Audit: mock fixtures only — wiring to real backends (Prometheus, OpenFeature, append-only audit) is tracked as follow-ups in OPERATOR-PLAN.md. - Placeholder pages (support/billing/reports/settings) via OpPlaceholder. - Shared: Stat, MetricCell, OpPlaceholder components, /api/users proxy, PlatformUser type. - .gitignore: scope the docker volumes data/ rule so apps/*/data/ is tracked again (operator carries mock fixtures there). |
||
|
|
fbbb43e3e2 |
feat(operator): partner management with attach/detach (O.6)
- Partners list with name/domain/status/customers/margin + Create modal - Partner detail: contract card, contact card, customers table, attach modal, terminate (soft-delete) danger card - Operator proxies for /partners + /partners/:slug/tenants - platform-api: add partnerId Prop to Tenant schema. The field was being silently dropped by Mongoose because the schema didn't declare it. - tenants.service: rewrite update() to build $set/$unset explicitly and cast partnerId via new Types.ObjectId(). Handles detach via $unset so the field vanishes from the doc cleanly. |
||
|
|
8e81730372 |
feat(operator): tenant list + 7-tab detail with real lifecycle (O.5)
Operator can now manage tenants end-to-end from the UI:
- pages/tenants/index.vue — list with status/plan/domains/created/
provisioning-state columns, search by slug or name, status chips
with live counts (all/active/pending/suspended), click-through
to detail
- pages/tenants/[slug].vue — 7-tab detail (Overview, Users, Resources,
Billing, Audit, Support, Danger zone)
- 3 tabs hit real backends: Overview (identity + billing fields),
Users (lazy-loaded via new GET /tenants/:slug/users endpoint),
Resources (live provisioning state per integration + Reconcile button)
- 3 tabs render mock fixtures with warn-tone "mock" badges: Billing
(Stripe placeholder), Audit (sample log lines), Support (placeholder
pending the ticket queue work)
- Danger zone: 3 real-backend cards (Suspend / Resume / Soft-delete),
each gated by a ConfirmDialog modal. Verified live — clicked
Suspend on acme, status flipped to 'suspended' in Mongo, then
Resumed back to 'active'
platform-api additions:
- GET /tenants/:slug/users returns users with this tenant in their
tenantIds, sorted by last login. Same authorization rule as the
existing /tenants/:slug — platform admins always pass,
non-admins must be a member of the tenant
- tenants.module imports User schema for the new lookup
New components (apps/operator/components/):
- Tabs.vue — horizontal strip with optional per-tab counts, v-model
- ConfirmDialog.vue — Teleport-to-body modal, Escape/backdrop close,
danger/primary tone for the confirm button
Server proxy infrastructure (apps/operator/server/):
- utils/platform-api.ts — single helper encapsulating
access-token-from-session + bearer-forward + error normalization.
Every operator proxy route is now a one-liner against this helper
- api/tenants/index.get.ts, [slug]/{index.get,index.patch,index.delete,
users.get,suspend.post,resume.post,reconcile.post}.ts
Two real bugs found and fixed during the smoke test:
- Mongoose subdocument `_id` leaks into JSON when iterating
tenant.provisioningStatus. Switched to an explicit
`['authentik', 'stalwart', 'ocis']` whitelist in both v-fors
- Documents created before provisioningErrors was added (like the
acme tenant) don't have the field at all in JSON. Use optional
chaining (`tenant.provisioningErrors?.[k]`) instead of bracket
access. Without it: 'Cannot read properties of undefined (reading
"authentik")' during the Resources tab render
|
||
|
|
8e6f73a921 |
feat(operator): design system port + persistent shell (O.4)
Operator portal now wears its real chrome instead of placeholder spans.
Sidebar + topbar + page header all rendered against the carbon palette
from tokens.css.
Components ported from the source design (operator-app.jsx,
platform-ui.jsx, operator-screens.jsx) as Vue 3 SFCs in
apps/operator/components/:
Foundation: NodeMark (copied from portal), UiIcon (expanded to 31 icons
covering sidebar/topbar/sort/arrows)
Primitives: Card (3 surface variants), UiButton (primary / secondary /
ghost / dark / danger × sm / md / lg), DataTable (header + rows),
Badge (7 tones), Avatar (deterministic palette by name hash), Mono,
Eyebrow, StatusDot, PageHeader (with actions slot)
Shell: OpSidebar (collapsible 232<->56px, 12 nav items in 4 sections,
active-row highlight from route, badge slot, brand + user footer);
OpTopbar (env badge with prod/staging/dev variants, palette trigger
stub for the ⌘K work in O.8, on-call pill, bell, avatar)
Layouts: layouts/default.vue wires sidebar + topbar + slot; layouts/blank.vue
is used by the login page (definePageMeta layout:'blank'). app.vue now
wraps NuxtPage in NuxtLayout (the missing piece — without it Nuxt warns
"Your project has layouts but the <NuxtLayout /> component has not been
used" and renders nothing chrome-wise).
Composable composables/useSidebar.ts holds the collapsed state shared
between OpSidebar's toggle button and layouts/default.vue's ⌘[ keyboard
shortcut.
Verified in the browser:
- Sidebar renders all 12 nav links with section dividers, env badge shows
PROD, PageHeader resolves to the user's display name from
useOidcAuth().user
- Collapse toggle flips sidebar width 232↔56; nav rows become icon-only
- Smoke test on the placeholder home still returns 409 for the seeded
test-partner (token forwarding survives the layout refactor)
Gotcha documented in the plan: Vite 7.3 added a strict
server.allowedHosts check that returns plaintext 403 for any host header
that isn't the dev origin. The customer portal pre-dates this Vite
version; operator needs allowedHosts: ['operator.dezky.local'] in
nuxt.config.ts under vite.server.
Pages/index.vue replaces the bare HTML placeholder from O.3 with the
new PageHeader + Card primitives — same smoke-test functionality, much
better visual fidelity.
Real screen content (Tenants, Partners, Infrastructure, etc.) lands in
O.5+. This commit is the chrome, the smoke test, and the verification
that the design system primitives compose correctly.
|
||
|
|
55b1c133e3 |
feat(operator): scaffold apps/operator Nuxt app + multi-issuer JWT (O.3)
New Nuxt 3 app at apps/operator/ — internal admin portal on its own domain
(operator.dezky.local), own OAuth client (dezky-operator), own session
secrets, own cookies. Customer and operator surfaces can't decrypt each
other's session state.
OAuth flow verified end-to-end:
- GET / → middleware redirect to /auth/login
- User clicks Sign in → /auth/oidc/login → bounces to Authentik with
client_id=dezky-operator, scope includes 'groups'
- Authentik checks dezky-platform-admins group binding (added in O.1),
silent-reauths via the existing auth.dezky.local session
- Returns to /auth/oidc/callback with code, exchanges for token,
creates session cookie on operator.dezky.local
- Lands on pages/index.vue placeholder dashboard
Smoke test 'Create partner "test-partner"' button on the placeholder home
exercises the full operator-only authorization chain:
- 1st call: 200, partner created in Mongo
- 2nd call: 409 'already exists' (idempotency holds, token still valid)
- Same call from the customer portal: 403 'requires operator-scoped
token' (audience guard rejects dezky-portal aud)
JwtAuthGuard now multi-issuer in addition to multi-audience. Each
Authentik OAuth provider mints tokens with its own per-app iss URL
(.../application/o/<slug>/), so the guard accepts a comma-separated
AUTHENTIK_ISSUER. The audience-only fix from O.2 wasn't sufficient —
issuer is validated separately by jose.jwtVerify and was still pinned
to dezky-portal alone, yielding 'unexpected iss claim value' rejections.
Compose changes: new 'operator' service (Node 20 alpine, pnpm install +
nuxt dev, mkcert CA mount, traefik labels for operator.dezky.local +
TLS); new operator_node_modules volume; operator.dezky.local added to
traefik's Docker network aliases. Distinct OPERATOR_NUXT_OIDC_* session
secrets pulled from .env (gitignored, generated via openssl).
Real operator screens (sidebar, topbar, tenants, partners, etc.) come
in O.4. This commit is pure scaffolding + the security boundary proof.
|
||
|
|
2db41fec5e |
feat(platform-api): multi-audience JWT + Partner CRUD + tenant lifecycle (O.2)
JwtAuthGuard now accepts a comma-separated AUTHENTIK_AUDIENCE
('dezky-portal,dezky-operator'). jose.jwtVerify takes an array and succeeds
on any match — both customer-portal and operator-portal tokens validate
against this service. Per-endpoint guards restrict further.
New OperatorGuard enforces operator-only mutations:
1. JWT audience claim includes 'dezky-operator' (proof from the token
alone that this is a privileged session)
2. ActorService-resolved User has platformAdmin=true (DB check so
revocation works without waiting for the token to expire)
Both required; either alone is insufficient.
Partner module:
- Partner schema: slug, name, domain, status, marginPct, contactInfo,
billingInfo. marginPct is one number per partner (decided in grilling)
- CRUD endpoints under @UseGuards(JwtAuthGuard, OperatorGuard) — every
partner mutation requires operator scope
- GET /partners returns each row with a computed customers count from
aggregating Tenant.partnerId. MRR aggregation deferred until
Subscription gains a price column
- GET /partners/:slug/tenants for the partner detail view
- DELETE soft-terminates (status='terminated') — never hard-delete
because tenants may still reference the partner
Tenant changes:
- partnerId?: Types.ObjectId (ref Partner, indexed sparse) added to
Tenant schema
- UpdateTenantDto accepts partnerId so PATCH can attach/detach
- POST /tenants/:slug/suspend and /resume — operator-only via
OperatorGuard. PATCH already covers plan/domains/partnerId changes
Smoke test: customer-portal session sends POST /api/partners through the
portal proxy → 403 "This endpoint requires an operator-scoped token". The
positive test (operator-token → 200) waits for O.3 when there's an
operator app to mint the right token.
apps/portal/server/api/partners/index.post.ts is a temporary verification
proxy — delete once the operator portal exists.
|
||
|
|
3573188431 |
docs(operator): O.1 done — Authentik dezky-operator OAuth client live
What landed in Authentik (runtime state, not in git):
- OAuth2 provider 'dezky-operator', confidential, PKCE, audience
dezky-operator, redirect URIs operator.dezky.local/auth/oidc/{callback,logout}
- Application 'Dezky Operator' linked to the provider
- Policy binding: dezky-platform-admins group required on the application
.env (gitignored) gained OPERATOR_OIDC_CLIENT_ID/SECRET/ISSUER.
MFA-required is deferred — Authentik enforces it via a stage binding on
the auth flow, which is app-specific config better tackled when there's
a real enrollment to gate. akadmin already has WebAuthn so the flow
prompts for it anyway.
Discovery doc at /application/o/dezky-operator/.well-known/openid-
configuration confirmed: issuer correct, scopes include 'groups'.
Two gotchas documented in OPERATOR-PLAN.md:
- Authentik 2025.10 requires invalidation_flow alongside authorization_flow
- policies/group_membership endpoint is gone; use policies/bindings with a
direct group reference instead
|
||
|
|
22b2583f0b |
chore(services): rename services/provisioning -> services/platform-api
O.0 prep from OPERATOR-PLAN.md. Mechanical refactor before adding partner management and operator-specific endpoints. The service now owns more than just provisioning orchestration (it'll soon own partners, tenant lifecycle actions, multi-audience JWT validation), so the name 'platform-api' reflects its scope better. What changed: - Directory: services/provisioning/ -> services/platform-api/ - Package: @dezky/provisioning -> @dezky/platform-api - Docker: container_name dezky-provisioning -> dezky-platform-api; compose service key 'provisioning' -> 'platform-api'; volume provisioning_node_modules -> platform_api_node_modules - Portal: PROVISIONING_INTERNAL_URL env var -> PLATFORM_API_INTERNAL_URL, default URL http://provisioning:3001 -> http://platform-api:3001 in all three proxy routes (me.get.ts, tenants/index.post.ts, tenants/[slug]/ reconcile.post.ts), plus NUXT_API_BASE updated - Health endpoint service identifier and main.ts log lines updated to 'dezky-platform-api' - Docs swept: README, CLAUDE.md, SERVICES.md, AUTHENTIK-SETUP.md, NEXT-STEPS.md, TROUBLESHOOTING.md, OPERATOR-PLAN.md, traefik/dynamic.yml What deliberately stays: - Internal module names ProvisioningService / ProvisioningModule (those describe an orchestration sub-concern, not the service's purpose) - Tenant.provisioningStatus / provisioningErrors field names (state per integration, not service name) - File services/platform-api/src/tenants/provisioning.service.ts - 'Hetzner provisioning' references in production-prep docs (infrastructure provisioning, unrelated) Verified end-to-end after rename: /api/me returns 200 with profile + 2 tenants + subscription, /api/tenants/dezky/reconcile returns 200 with Authentik integration still ok. OPERATOR-PLAN.md O.0 checkboxes ticked. |
||
|
|
fb3d7aa716 |
docs: add execution checklist to OPERATOR-PLAN
Ten phases (O.0–O.9), each ~one commit, in dependency order. Lets us tick boxes as work lands and surfaces what's blocking what. |
||
|
|
92c5056a1d |
docs: capture operator portal plan from grilling session
OPERATOR-PLAN.md records the decisions from the design review: - Scope: C-visual (full UI fidelity, mock data for most screens) but real CRUD for tenants and partners from day one - Lives at apps/operator/ as a separate Nuxt app, separate domain, separate Authentik OAuth client (dezky-operator), aud-claim distinguishes operator vs portal tokens - Backend stays as a single NestJS service; rename services/provisioning -> services/platform-api as a prep commit - Partner schema designed: slug/name/domain/status/marginPct/contactInfo; Tenant gains optional partnerId; counts and MRR are computed at query time - Impersonation: visual stub now (modal + banner, no-op toast); real OAuth Token Exchange flow recorded as the first follow-up task Also lists follow-up tasks (real audit log, feature flag backend, incident management, partner portal) and out-of-scope items so the next grilling session has a starting point. Pointer added in NEXT-STEPS.md under a new 'Operator portal' track. |
||
|
|
467e6a7ab5 |
docs: mark Phase 4 partial — Authentik real, Stalwart + OCIS stubbed
Honest status for the data-model + provisioning phases. Lists the smoke test that verified the chain works end-to-end and points at the upstream docs for the JMAP and libregraph follow-up work. |
||
|
|
28766b80c2 |
feat(provisioning): orchestrate Authentik/Stalwart/OCIS on tenant create
Phase 4 from docs/NEXT-STEPS.md. POST /tenants now writes Mongo AND drives external service provisioning. A new POST /tenants/:slug/reconcile endpoint retries the orchestration — useful when an upstream was down at create time or external state drifted out of band. Integration clients (services/provisioning/src/integrations/): - AuthentikClient: real implementation. ensureGroup() is idempotent — looks up the group by name, creates if missing, returns either way. Group attributes record the tenant slug + Mongo id so we can trace back - StalwartClient: stubbed. v0.16 removed the REST management API in favor of JMAP, which is significantly more work to wrap. TODO comment points to https://stalw.art/docs/api/management/overview for the follow-up - OcisClient: stubbed. Needs libregraph /drives endpoint with service-to- service auth via OIDC client_credentials Orchestration (provisioning.service.ts): - Each step runs independently; one failure doesn't roll back the others - Per-step state recorded on Tenant.provisioningStatus (ok/skipped/error/ pending) plus error message on Tenant.provisioningErrors - Steps return their own terminal state — 'skipped' for stubs, void defaults to 'ok' for real integrations - Mongoose markModified() required for nested subdoc mutations to persist - Tenant auto-flips status: pending → active when all steps are ok|skipped Portal proxy routes (apps/portal/server/api/tenants/): - POST /api/tenants and POST /api/tenants/:slug/reconcile forward the signed-in user's access token to the provisioning service. Lets the browser drive provisioning without minting tokens by hand. Will be replaced by a real "create workspace" flow with UI later docker-compose: AUTHENTIK_API_URL/STALWART_API_URL/OCIS_API_URL now point at the public Traefik-routed hostnames (with mkcert CA mounted into the provisioning container so Node fetch trusts them). Previously these pointed at internal Docker hostnames which doesn't work for Authentik because of TLS issuer mismatch against the JWT. |
||
|
|
4bf6a85517 |
fix(stalwart): wire recovery admin + point portal tile at admin UI
- docker-compose: add STALWART_RECOVERY_ADMIN env so the env-file password works as a permanent recovery login. Without this, Stalwart prints a one-time bootstrap password to the logs and discards it after first setup - portal: mail tile now links to /admin/ (the real Stalwart admin SPA), not /login (which is the OAuth client authorization UI for IMAP/SMTP clients like Thunderbird — confusing and unrelated) The persistent admin (admin@dezky.local) was created via Stalwart's setup wizard at /admin/init and lives in the stalwart_data volume. Recovery admin in env is the "I lost the wizard credentials" escape hatch. |
||
|
|
e0808bf13e |
fix(ocis): wire OCIS web SSO + Collabora document editing end to end
OCIS SSO was loading the SPA but never redirecting to Authentik: the default OCIS CSP only allows connect-src to itself + the awesome-ocis GitHub repo, so the metadata fetch to auth.dezky.local was blocked. Mount a custom csp.yaml and point PROXY_CSP_CONFIG_FILE_LOCATION at it (env var lives on the proxy service, not web — easy mistake). Also added the .html OIDC callback URIs to the ocis-provider in Authentik (run-time state, not in this commit). Collabora document editing required adding the OCIS collaboration service — the WOPI bridge between OCIS storage and Collabora. Key wiring: - ocis: expose embedded NATS (NATS_NATS_HOST=0.0.0.0) and gateway (GATEWAY_GRPC_ADDR=0.0.0.0:9142) so the new container can register and reach the rest of OCIS over the Docker network - collaboration: COLLABORATION_GRPC_ADDR=0.0.0.0:9301 so it registers itself in the service registry with a reachable address (default 127.0.0.1 was unreachable from cross-container callers) - collaboration: APP_ADDR uses the public host (office.dezky.local), not the internal Docker hostname — this value is sent to the browser as the iframe src - collabora: regenerate proof key on every start (coolconfig generate-proof-key) so its public key matches what coolwsd signs with; otherwise collaboration rejects WOPI calls with "ProofKeys verification failed" - collabora: ssl_verification=false (mkcert root not in Collabora's trust store), frame_ancestors=files.dezky.local (otherwise the iframe is blocked with a Danish "Indhold blokeret"), home_mode.enable=true to drop the "Explore The New" welcome popup and feedback prompt - ocis CSP: extend connect-src + frame-src to include the new hostnames Result: opening a .docx from OCIS now embeds Collabora in an iframe and the document opens for editing. Dev-mode caveats (not for prod): TLS verification disabled on Collabora's outbound WOPI calls; home_mode caps at 20 concurrent connections / 10 docs. |
||
|
|
3d370caa62 |
feat(provisioning): tenant data model + CRUD with JWT-validated authz
Implements Phase 3 from docs/NEXT-STEPS.md. Mongoose schemas (services/provisioning/src/schemas/): - Tenant: slug, name, status, plan, domains, billingInfo, plus handles for Authentik group, OCIS space, and Stalwart domain (set in Phase 4) - User: authentikSubjectId, tenantIds[], email, name, role, platformAdmin flag - Subscription: tenantId, plan, status, Stripe IDs (unused until Phase 4) Auth (services/provisioning/src/auth/): - JwtAuthGuard verifies Authentik access tokens against the provider's JWKS with issuer + audience checks. Uses NODE_EXTRA_CA_CERTS to trust the mkcert root for the local Authentik cert - ActorService resolves the verified JWT into a Mongo User document — every controller reads tenantIds + platformAdmin from the DB, not the token - CurrentUser decorator extracts the JWT payload onto controllers CRUD modules: - /tenants, /users, /subscriptions with create/read/update/delete - /users/me upserts the caller's User record on every request, syncing email, name, tenantIds, and platformAdmin from the JWT's groups claim — the only place we read JWT.groups outside the bootstrap Why DB-derived authz: putting all group memberships in the JWT doesn't scale past ~50 tenants per user (header/cookie size limits, no mid-session revocation, stale data until re-login). JWT now carries identity only; the DB is the source of truth for who can see what. Seed (SeedService.OnApplicationBootstrap): idempotent creation of the default 'dezky' tenant + matching subscription. User records are created on first /users/me hit. Infrastructure: - Traefik label exposes provisioning at https://api.dezky.local (dev only) - api.dezky.local added to Docker network aliases on Traefik - mkcert root CA mounted into the provisioning container for JWKS fetch - Authentik 'groups' scope mapping created + attached to dezky-portal provider; portal now requests it as a scope - nuxt.config.ts portal: exposeAccessToken=true so Nitro forwards token; NUXT_OIDC_TOKEN_KEY fixed to base64-encoded 32 bytes (was hex, causing "Invalid key length" once exposeAccessToken turned on) Portal: apps/portal/server/api/me.get.ts is a scaffolding route that forwards the user's access token to provisioning and returns profile + tenants + subscriptions — verifies the full chain end to end. |
||
|
|
adfd9baafe |
chore: initial scaffold with running local stack and portal auth
Brings up Dezky's local development environment end-to-end: Infrastructure (docker-compose): - Traefik v3.7 reverse proxy with mkcert TLS (v3.2 couldn't speak Docker API 1.54) - Postgres + Mongo + Redis with healthchecks and init script for per-service users - Authentik 2025.10 (server + worker) as OIDC IdP - Stalwart v0.16 mail server (image renamed from stalwartlabs/mail-server) - OCIS 7.0 with PROXY_TLS=false and OCIS_CONFIG_DIR=/etc/ocis so init writes where the server reads - Collabora office, plus the portal + provisioning service stubs - Docker network aliases on Traefik so containers resolve auth.dezky.local etc. through the network (not host /etc/hosts) - Docker socket mount parameterized for macOS Docker Desktop symlink path Authentik provisioning (done via API after stack boot): - ocis-provider (public client) + OCIS Files application - dezky-portal provider (confidential) + Dezky Portal application - Admin API token bound to akadmin manually since 2025.10's AUTHENTIK_BOOTSTRAP_TOKEN env var doesn't auto-materialize a token row Portal (apps/portal): - Nuxt 3 with nuxt-oidc-auth 1.0.0-beta.11 against generic 'oidc' preset - Global auth middleware; login at /auth/oidc/login redirects to Authentik - Visual implementation of Claude Design 'Auth' canvas: AuthShell, NodeMark, Auth* sub-components, design tokens as CSS custom properties - Pages: auth/login, auth/expired, auth/disabled, index (post-login landing) - mkcert root CA mounted into the portal so Node fetch trusts Authentik's self-signed cert (NODE_EXTRA_CA_CERTS) — dev only Docs: - AUTHENTIK-SETUP.md updated with manual token bind + portal provider scripted alternative - NEXT-STEPS.md: Phase 1 and Phase 2 marked done with file locations and dev-mode caveats Dev-mode shortcuts that need to be revisited before prod: - skipAccessTokenParsing on the OIDC config - NODE_EXTRA_CA_CERTS mkcert mount - Bootstrap password still the generated value in .env - Authentik admin token (dezky-bootstrap-token) is non-expiring |