diff --git a/.env.example b/.env.example index 35147fd..f57a036 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,11 @@ OCIS_ADMIN_PASSWORD=changeme_use_openssl_rand # (required to list all drives). See docs/NEXT-STEPS.md. OCIS_SVC_USERNAME=svc-platform-api OCIS_SVC_PASSWORD=changeme_use_openssl_rand +# OCIS account UUID of the service user, used to grant it the OCIS admin role at +# startup (required for libregraph ListAllDrives). Populate after the OCIS +# bootstrap autoprovisions the account (see docs/NEXT-STEPS.md). Leave empty +# until then. +OCIS_ADMIN_USER_ID= # ──────────────────────────────────────── # Collabora diff --git a/apps/portal/pages/admin/index.vue b/apps/portal/pages/admin/index.vue index 8f8c49c..828d318 100644 --- a/apps/portal/pages/admin/index.vue +++ b/apps/portal/pages/admin/index.vue @@ -3,10 +3,11 @@ // `AdminDashboard`, but the data is real: workspace identity, seats, spend, // plan and recent admin events all come from /api/me + /api/tenants/:slug/*. // -// Sections without a real backend source yet (storage usage, mail-flow health, -// "open issues" like DMARC/failed-login heuristics) were removed rather than -// faked — they return when their backends (OCIS metrics, Stalwart metrics, a -// domain-health checker) exist. +// Storage usage is real now too (OCIS libregraph via /tenants/:slug/storage) — +// shown as a second capacity bar in the Plan card. Sections still without a +// backend (mail-flow health, "open issues" like DMARC/failed-login heuristics) +// stay removed rather than faked until Stalwart metrics / a domain-health +// checker exist. import type { IconName } from '~/components/UiIcon.vue' import type { AuditEventDoc, TenantUserDoc } from '~/types/workspace' @@ -30,6 +31,20 @@ const { data: auditRaw } = await useFetch( { key: 'admin-dash-audit', default: () => [], immediate: !!slug.value, watch: [slug] }, ) +// Aggregate storage usage (OCIS) — second capacity bar in the Plan card. +interface StorageSummary { + available: boolean + usedBytes: number + quotaBytes: number + freeBytes: number +} +const { data: storage } = await useFetch( + () => `/api/tenants/${slug.value}/storage`, + { key: 'admin-dash-storage', default: () => null, immediate: !!slug.value, watch: [slug] }, +) +const storageAvailable = computed(() => storage.value?.available === true) +const storagePct = computed(() => percent(storage.value?.usedBytes ?? 0, storage.value?.quotaBytes ?? 0)) + const seatsUsed = computed(() => (users.value ?? []).filter((u) => u.active !== false).length) const seatsAvailable = computed(() => Math.max(0, seatLimit.value - seatsUsed.value)) const seatPct = computed(() => @@ -149,6 +164,7 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal.val
+
Seats
{{ seatsUsed }} active @@ -156,6 +172,15 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal.val
+
+
Storage
+
+
+ {{ formatBytes(storage!.usedBytes) }} used + {{ formatBytes(storage!.freeBytes) }} free +
+
+
Approaching limit? You can add seats in single increments — billed prorated. @@ -335,6 +360,14 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal.val /* License progress */ .progress-block { margin-bottom: 16px; } +.bar-label { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-mute); + margin-bottom: 8px; +} .progress-bar { height: 8px; background: var(--bg); diff --git a/docs/NEXT-STEPS.md b/docs/NEXT-STEPS.md index 80a1f6c..79da1c2 100644 --- a/docs/NEXT-STEPS.md +++ b/docs/NEXT-STEPS.md @@ -124,10 +124,29 @@ Need a minimal JMAP client that wraps `Domain/set` (create), `Domain/get` via the persistent admin's bearer token from the OAuth flow we already use for the web UI. -**OCIS (libregraph)** — `POST /graph/v1.0/drives` with body -`{ "name": "", "driveType": "project" }`. Needs service-to-service -auth: either an OIDC client_credentials grant (requires registering a new -Authentik provider for the worker) or the IDM admin user's bearer token. +**OCIS (libregraph)** — space *provisioning* is still stubbed: +`POST /graph/v1.0/drives` with body `{ "name": "", "driveType": +"project" }` to create a tenant's project space, then assign it. + +**OCIS read auth (done — powers the customer-admin Storage page).** OCIS has +*no* backend service-account/client-credentials grant and trusts exactly one +issuer, and basic auth doesn't resolve a user in our external-IdP setup. The +working mechanism is a **refresh-token bootstrap**: + +1. A dedicated Authentik user `svc-platform-api` (with an email — OCIS + autoprovision rejects empty emails) logs in **once** against the *ocis* + provider (public client `ocis-web`, per-provider issuer `.../o/ocis/` — the + one OCIS trusts). Run it headlessly: + `docker compose exec platform-api node /app/scripts/bootstrap-ocis.mjs`. + The refresh token is persisted in Mongo (`ocis_credentials`). +2. `OcisClient` mints access tokens with the `refresh_token` grant and persists + the rotated token each call (Authentik rotates on every use). +3. The svc user needs the OCIS **admin** role for `ListAllDrives` — granted via + `OCIS_ADMIN_USER_ID=` on the ocis service. + +Note: the "global" issuer mode is **not** an option — its issuer is the +Authentik root, which has no `.well-known/openid-configuration`, so OCIS can't +validate tokens against it. ### Authentik API examples (for the eventual user-creation flow) diff --git a/infrastructure/docker-compose/docker-compose.yml b/infrastructure/docker-compose/docker-compose.yml index 5cc7d02..bed486c 100644 --- a/infrastructure/docker-compose/docker-compose.yml +++ b/infrastructure/docker-compose/docker-compose.yml @@ -325,7 +325,12 @@ services: PROXY_AUTOPROVISION_ACCOUNTS: "true" PROXY_USER_OIDC_CLAIM: preferred_username PROXY_USER_CS3_CLAIM: username - OCIS_ADMIN_USER_ID: "" + # Grant the OCIS admin role to the platform-api service user (autoprovisioned + # OCIS account of svc-platform-api). Admin is required for libregraph + # ListAllDrives, which powers the customer-admin Storage page. The UUID is + # the svc user's OCIS account id; stable as long as the OCIS data volume + # persists. Empty in fresh setups until the OCIS bootstrap has run. + OCIS_ADMIN_USER_ID: ${OCIS_ADMIN_USER_ID:-} IDM_CREATE_DEMO_USERS: "false" IDM_ADMIN_PASSWORD: ${OCIS_ADMIN_PASSWORD} STORAGE_USERS_DRIVER: ocis # Local filesystem in dev diff --git a/services/platform-api/scripts/bootstrap-ocis.mjs b/services/platform-api/scripts/bootstrap-ocis.mjs new file mode 100644 index 0000000..cd72824 --- /dev/null +++ b/services/platform-api/scripts/bootstrap-ocis.mjs @@ -0,0 +1,145 @@ +// One-time bootstrap: headless OIDC login of the OCIS service user against the +// ocis provider (public client `ocis-web`) to obtain a refresh token, then +// persist it into Mongo (ocis_credentials). The running platform-api then mints +// access tokens with the refresh_token grant (see OcisClient). Re-run this if +// the refresh token is ever revoked/expired. +// +// Run inside the platform-api container (it has the mkcert CA + Mongo access): +// docker compose exec platform-api node /app/scripts/bootstrap-ocis.mjs +// Requires env: AUTHENTIK_API_URL, OCIS_SVC_USERNAME, OCIS_SVC_PASSWORD, MONGODB_URI. +import crypto from 'node:crypto' +import mongoose from 'mongoose' + +const API = process.env.AUTHENTIK_API_URL // https://auth.dezky.local/api/v3 +const ROOT = API.replace('/api/v3', '') +const USER = process.env.OCIS_SVC_USERNAME +const PASS = process.env.OCIS_SVC_PASSWORD +const CLIENT_ID = 'ocis-web' +const REDIRECT = 'https://files.dezky.local/oidc-callback' +const SCOPE = 'openid profile email offline_access' + +const b64url = (b) => b.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +const verifier = b64url(crypto.randomBytes(32)) +const challenge = b64url(crypto.createHash('sha256').update(verifier).digest()) +const state = b64url(crypto.randomBytes(8)) + +// --- tiny cookie jar --- +const jar = new Map() +function store(res) { + for (const c of res.headers.getSetCookie?.() ?? []) { + const [pair] = c.split(';') + const i = pair.indexOf('=') + jar.set(pair.slice(0, i).trim(), pair.slice(i + 1).trim()) + } +} +const cookieHeader = () => [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ') +const csrf = () => jar.get('authentik_csrf') + +async function main() { + const authzQuery = new URLSearchParams({ + response_type: 'code', + client_id: CLIENT_ID, + redirect_uri: REDIRECT, + scope: SCOPE, + state, + code_challenge: challenge, + code_challenge_method: 'S256', + }).toString() + + // 1) Hit authorize → 302 into the authentication flow; capture session cookies + // AND the flow's `next` query (so the executor resumes THIS authorize plan, + // not the brand's default landing app). + let res = await fetch(`${ROOT}/application/o/authorize/?${authzQuery}`, { redirect: 'manual' }) + store(res) + const loc1 = res.headers.get('location') || '' + console.log('authorize ->', res.status, loc1.slice(0, 80)) + // The executor takes the flow URL's whole querystring wrapped in a single + // `query` param — that's how `next` (the authorize continuation) is carried. + const flowQuery = loc1.includes('?') ? loc1.slice(loc1.indexOf('?') + 1) : `next=${encodeURIComponent('/application/o/authorize/?' + authzQuery)}` + + // 2) Run the authentication flow via the executor API (identification → password). + const exec = `${ROOT}/api/v3/flows/executor/default-authentication-flow/?query=${encodeURIComponent(flowQuery)}` + const hdr = () => ({ + 'Content-Type': 'application/json', + Accept: 'application/json', + Cookie: cookieHeader(), + 'X-Authentik-CSRF': csrf() ?? '', + Referer: `${ROOT}/`, + }) + + // Unified flow driver: Authentik mixes JSON stages (POST answers) with + // HTTP redirects (xak-flow-redirect / 302). Loop until we land on the + // redirect_uri with a ?code=. + const abs = (u) => (u.startsWith('http') ? u : ROOT + u) + let url = exec + let method = 'GET' + let body = null + let code + for (let i = 0; i < 14 && !code; i++) { + res = await fetch(url, { method, headers: hdr(), body, redirect: 'manual' }) + store(res) + if (res.status >= 300 && res.status < 400) { + const loc = res.headers.get('location') || '' + console.log(' redirect ->', loc.slice(0, 80)) + if (loc.startsWith(REDIRECT)) { code = new URL(loc).searchParams.get('code'); break } + url = abs(loc); method = 'GET'; body = null + continue + } + const stage = await res.json().catch(() => ({})) + console.log(' stage:', stage.component, stage.response_errors ? JSON.stringify(stage.response_errors).slice(0, 120) : '') + switch (stage.component) { + case 'ak-stage-identification': { + const payload = { uid_field: USER } + if (stage.password_fields) payload.password = PASS + method = 'POST'; body = JSON.stringify(payload); url = exec; break + } + case 'ak-stage-password': + method = 'POST'; body = JSON.stringify({ password: PASS }); url = exec; break + case 'xak-flow-redirect': { + const to = stage.to || '' + if (to.startsWith(REDIRECT)) { code = new URL(to).searchParams.get('code'); break } + url = abs(to); method = 'GET'; body = null; break + } + default: + throw new Error('unexpected stage: ' + JSON.stringify(stage).slice(0, 300)) + } + } + if (!code) throw new Error('no authorization code captured') + console.log('CODE captured') + + // 4) Exchange code → tokens. + const tokenBody = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: REDIRECT, + client_id: CLIENT_ID, + code_verifier: verifier, + }) + res = await fetch(`${ROOT}/application/o/token/`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, + body: tokenBody, + }) + const tok = await res.json() + console.log('token ->', res.status, tok.error || `refresh_token:${tok.refresh_token ? 'yes' : 'no'} scope:${tok.scope}`) + if (!tok.refresh_token) throw new Error('no refresh_token: ' + JSON.stringify(tok).slice(0, 200)) + + // Sanity: decode access token issuer + identity. + const claims = JSON.parse(Buffer.from(tok.access_token.split('.')[1], 'base64').toString()) + console.log('iss:', claims.iss, '| preferred_username:', claims.preferred_username) + + // 5) Persist refresh token. + await mongoose.connect(process.env.MONGODB_URI) + await mongoose.connection.collection('ocis_credentials').updateOne( + { key: 'ocis-svc' }, + { $set: { key: 'ocis-svc', refreshToken: tok.refresh_token, updatedAt: new Date() }, $setOnInsert: { createdAt: new Date() } }, + { upsert: true }, + ) + await mongoose.disconnect() + console.log('PERSISTED refresh token to ocis_credentials') +} + +main().catch((e) => { + console.log('BOOTSTRAP ERROR:', String(e).slice(0, 400)) + process.exit(1) +}) diff --git a/services/platform-api/src/integrations/integrations.module.ts b/services/platform-api/src/integrations/integrations.module.ts index d76eba2..0f8380e 100644 --- a/services/platform-api/src/integrations/integrations.module.ts +++ b/services/platform-api/src/integrations/integrations.module.ts @@ -1,10 +1,16 @@ import { Module } from '@nestjs/common' +import { MongooseModule } from '@nestjs/mongoose' +import { OcisCredential, OcisCredentialSchema } from '../schemas/ocis-credential.schema.js' import { AuthentikClient } from './authentik.client.js' import { OcisClient } from './ocis.client.js' import { StalwartClient } from './stalwart.client.js' import { StripeClient } from './stripe.client.js' @Module({ + imports: [ + // OcisClient persists the rotating OCIS service refresh token here. + MongooseModule.forFeature([{ name: OcisCredential.name, schema: OcisCredentialSchema }]), + ], providers: [AuthentikClient, StalwartClient, OcisClient, StripeClient], exports: [AuthentikClient, StalwartClient, OcisClient, StripeClient], }) diff --git a/services/platform-api/src/integrations/ocis.client.ts b/services/platform-api/src/integrations/ocis.client.ts index a31e550..9f8655e 100644 --- a/services/platform-api/src/integrations/ocis.client.ts +++ b/services/platform-api/src/integrations/ocis.client.ts @@ -1,5 +1,8 @@ import { Injectable, Logger } from '@nestjs/common' import { ConfigService } from '@nestjs/config' +import { InjectModel } from '@nestjs/mongoose' +import { Model } from 'mongoose' +import { OcisCredential, OcisCredentialDocument } from '../schemas/ocis-credential.schema.js' // A libregraph quota object as returned on each drive. All byte counts are // integers; `total` of 0 means "unlimited" in OCIS (the default in dev). @@ -27,69 +30,94 @@ export interface OcisUser { mail?: string } -// OCIS integration. Provisioning (ensureSpace/deleteSpace) is still stubbed — -// it needs the project-space create call (see docs/NEXT-STEPS.md). The READ -// layer below is real: it lists per-drive quota for the customer-admin Storage -// page via the libregraph /graph/v1.0 API. +const CREDENTIAL_KEY = 'ocis-svc' + +// OCIS integration. Provisioning (ensureSpace/deleteSpace) is still stubbed. +// The READ layer is real: it lists per-drive quota for the customer-admin +// Storage page via the libregraph /graph/v1.0 API. // -// Auth: OCIS has no built-in service-account/client-credentials grant for -// backend access (ownCloud devs: "one needs to go through OIDC authentication -// to obtain an access token"), and it trusts exactly one issuer. So we run an -// OIDC Resource-Owner-Password grant against the SAME Authentik provider OCIS -// trusts (client `ocis-web`), as a dedicated service user that holds the OCIS -// admin role (required to list all drives). The short-lived token is cached and -// refreshed in memory. Basic auth (PROXY_ENABLE_BASIC_AUTH) doesn't resolve the -// IDM admin in our external-IdP setup, hence this route. +// Auth is the hard part. OCIS has no backend service-account/client-credentials +// grant and trusts exactly one issuer; basic auth doesn't resolve a user in our +// external-IdP setup. The only mechanism that works without reconfiguring SSO is +// a refresh-token bootstrap: a one-time interactive OIDC login of the service +// user (svc-platform-api) against the SAME provider OCIS trusts (`ocis-web`, +// per-provider issuer .../o/ocis/) yields a refresh token. We persist it and +// mint access tokens with the refresh_token grant from then on. Authentik +// rotates the refresh token on every use, so we persist the new one each time. +// The service user must hold the OCIS admin role (required to list all drives). @Injectable() export class OcisClient { private readonly logger = new Logger(OcisClient.name) private readonly base: string private readonly tokenUrl?: string private readonly clientId?: string - private readonly clientSecret?: string - private readonly username?: string - private readonly password?: string private readonly scope: string - // In-memory token cache. Tokens live minutes; we re-grant when within the - // skew window. Never persisted — same lifecycle as the process. - private token?: string - private tokenExpiresAt = 0 + // In-memory access-token cache (tokens live minutes). The refresh token lives + // in Mongo because it rotates and must survive restarts. + private accessToken?: string + private accessExpiresAt = 0 + // Single-flight guard so concurrent storage requests don't race the rotating + // refresh token (each refresh invalidates the previous one). + private refreshing?: Promise - constructor(config: ConfigService) { + constructor( + config: ConfigService, + @InjectModel(OcisCredential.name) private readonly credModel: Model, + ) { this.base = config.getOrThrow('OCIS_API_URL') this.tokenUrl = config.get('OCIS_OIDC_TOKEN_URL') || undefined this.clientId = config.get('OCIS_OIDC_CLIENT_ID') || undefined - this.clientSecret = config.get('OCIS_OIDC_CLIENT_SECRET') || undefined - this.username = config.get('OCIS_SVC_USERNAME') || undefined - this.password = config.get('OCIS_SVC_PASSWORD') || undefined - this.scope = config.get('OCIS_OIDC_SCOPE') || 'openid profile email' + // offline_access is what makes Authentik issue a refresh token on bootstrap. + this.scope = config.get('OCIS_OIDC_SCOPE') || 'openid profile email offline_access' } - // True once we have everything needed to mint a token. When false the read - // methods short-circuit so the Storage page renders an "unavailable" state - // instead of erroring. + // Static config present. Whether we actually have a bootstrapped refresh token + // is determined per-call (DB lookup) — a missing token surfaces as the page's + // "unavailable" state, not a startup failure. get configured(): boolean { - return !!(this.tokenUrl && this.clientId && this.username && this.password) + return !!(this.tokenUrl && this.clientId) } - // ROPC (password) grant against the ocis provider's token endpoint. Cached - // until ~30s before expiry. The resulting token's issuer matches - // OCIS_OIDC_ISSUER and its preferred_username maps to the service user. - private async getToken(): Promise { - if (this.token && Date.now() < this.tokenExpiresAt) return this.token + // Seed/replace the refresh token (called by the one-time bootstrap). + async setRefreshToken(refreshToken: string): Promise { + await this.credModel.updateOne( + { key: CREDENTIAL_KEY }, + { $set: { refreshToken } }, + { upsert: true }, + ) + this.accessToken = undefined + this.accessExpiresAt = 0 + } + private async loadRefreshToken(): Promise { + const doc = await this.credModel.findOne({ key: CREDENTIAL_KEY }).lean().exec() + if (!doc?.refreshToken) { + throw new Error('OCIS service refresh token not bootstrapped — run the OCIS bootstrap') + } + return doc.refreshToken + } + + private async getToken(): Promise { + if (this.accessToken && Date.now() < this.accessExpiresAt) return this.accessToken + if (this.refreshing) return this.refreshing + this.refreshing = this.refreshAccessToken().finally(() => { + this.refreshing = undefined + }) + return this.refreshing + } + + // Exchange the stored refresh token for a fresh access token, persisting the + // rotated refresh token Authentik hands back. Public client (ocis-web) → no + // client secret. The resulting token's issuer matches OCIS_OIDC_ISSUER. + private async refreshAccessToken(): Promise { + const refreshToken = await this.loadRefreshToken() const body = new URLSearchParams({ - grant_type: 'password', + grant_type: 'refresh_token', client_id: this.clientId!, - username: this.username!, - password: this.password!, + refresh_token: refreshToken, scope: this.scope, }) - // Public clients (ocis-web) omit the secret; included only if configured - // (e.g. a confidential service provider). - if (this.clientSecret) body.set('client_secret', this.clientSecret) - const res = await fetch(this.tokenUrl!, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, @@ -97,13 +125,27 @@ export class OcisClient { }) if (!res.ok) { const text = await res.text().catch(() => '') - throw new Error(`OCIS token grant → ${res.status}: ${text.slice(0, 200)}`) + // A revoked/expired refresh token means a human must re-bootstrap. + this.logger.warn( + `OCIS token refresh → ${res.status}: ${text.slice(0, 160)} — re-bootstrap the OCIS service login if this persists`, + ) + throw new Error(`OCIS token refresh failed (${res.status})`) } - const json = (await res.json()) as { access_token?: string; expires_in?: number } - if (!json.access_token) throw new Error('OCIS token grant returned no access_token') - this.token = json.access_token - this.tokenExpiresAt = Date.now() + Math.max(0, (json.expires_in ?? 300) - 30) * 1000 - return this.token + const json = (await res.json()) as { + access_token?: string + expires_in?: number + refresh_token?: string + } + if (!json.access_token) throw new Error('OCIS token refresh returned no access_token') + // Persist the rotated refresh token (Authentik rotates on every use). + if (json.refresh_token && json.refresh_token !== refreshToken) { + await this.credModel + .updateOne({ key: CREDENTIAL_KEY }, { $set: { refreshToken: json.refresh_token } }) + .exec() + } + this.accessToken = json.access_token + this.accessExpiresAt = Date.now() + Math.max(0, (json.expires_in ?? 300) - 30) * 1000 + return this.accessToken } private async request(path: string): Promise { @@ -112,11 +154,10 @@ export class OcisClient { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, }) if (!res.ok) { - // A 401 likely means a stale cached token; drop it so the next call - // re-grants. (One retry is enough; callers degrade gracefully on error.) + // Drop a possibly-stale access token so the next call re-refreshes. if (res.status === 401) { - this.token = undefined - this.tokenExpiresAt = 0 + this.accessToken = undefined + this.accessExpiresAt = 0 } const body = await res.text().catch(() => '') throw new Error(`OCIS GET ${path} → ${res.status}: ${body.slice(0, 200)}`) @@ -145,7 +186,6 @@ export class OcisClient { // ── Provisioning (stubbed) ──────────────────────────────────────────────── // Real implementation needs POST /graph/v1.0/drives { name, driveType: // 'project' } to create a space and assign it to the tenant's group / users. - // Phase 4 ships the orchestration; this hooks up in a follow-up. async ensureSpace(slug: string): Promise<{ id: string }> { this.logger.warn(`OCIS space provisioning is stubbed — would create space for "${slug}" at ${this.base}`) return { id: `stub-${slug}` } diff --git a/services/platform-api/src/schemas/ocis-credential.schema.ts b/services/platform-api/src/schemas/ocis-credential.schema.ts new file mode 100644 index 0000000..3ab5efe --- /dev/null +++ b/services/platform-api/src/schemas/ocis-credential.schema.ts @@ -0,0 +1,22 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { HydratedDocument } from 'mongoose' + +export type OcisCredentialDocument = HydratedDocument + +// Persisted OIDC refresh token for the OCIS service user (svc-platform-api). +// OCIS has no backend service-account grant and trusts a single issuer, so we +// obtain a token via a one-time interactive OIDC login of the service user +// against the ocis provider, then keep minting access tokens with the +// refresh_token grant. Authentik ROTATES refresh tokens on every use, so the +// newest one must be persisted each time — that's the whole reason this lives +// in Mongo rather than memory/env. Single-row, keyed by `key`. +@Schema({ collection: 'ocis_credentials', timestamps: true }) +export class OcisCredential { + @Prop({ required: true, unique: true, index: true, default: 'ocis-svc' }) + key!: string + + @Prop({ required: true }) + refreshToken!: string +} + +export const OcisCredentialSchema = SchemaFactory.createForClass(OcisCredential)