@k2b/cloud 0.47.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/_internal/app-access-manager-sql.ts +10 -0
- package/src/_internal/app-access-resources.ts +2 -1
- package/src/_internal/app-capabilities.ts +73 -0
- package/src/_internal/app-event-catalog.ts +1 -1
- package/src/_internal/app-queries.ts +1 -1
- package/src/_internal/application-package.ts +2 -1
- package/src/_internal/bounded-json.ts +7 -1
- package/src/_internal/capabilities.ts +25 -2
- package/src/_internal/define-app.ts +33 -2
- package/src/_internal/heartbeat.ts +2 -0
- package/src/_internal/nats-connection.ts +10 -9
- package/src/_internal/process-sync.ts +6 -6
- package/src/_internal/public-surface.ts +76 -0
- package/src/_internal/purpose-name.ts +7 -0
- package/src/_internal/registry-validation.ts +1 -0
- package/src/_internal/registry.ts +49 -3
- package/src/_internal/workload-credential.ts +2 -1
- package/src/ai/app-skill-store.ts +2 -1
- package/src/ai/app-skills.ts +2 -1
- package/src/ai/approvals.ts +2 -1
- package/src/ai/chat-tasks.ts +2 -1
- package/src/ai/credentials.ts +2 -1
- package/src/ai/dictation-runtime.ts +1 -1
- package/src/ai/dictations.ts +1 -1
- package/src/ai/files-store.ts +2 -1
- package/src/ai/inference-calls.ts +2 -1
- package/src/ai/map-store.ts +1 -1
- package/src/ai/map-tool.ts +1 -1
- package/src/ai/memories.ts +1 -1
- package/src/ai/memory-learning-runs.ts +1 -1
- package/src/ai/memory-learning.ts +1 -1
- package/src/ai/memory-workflow-evidence.ts +1 -1
- package/src/ai/message-queue.ts +2 -1
- package/src/ai/migrate.ts +1 -1
- package/src/ai/model-access-migrate.ts +1 -1
- package/src/ai/model-access.ts +1 -1
- package/src/ai/model-catalog.ts +1 -1
- package/src/ai/model-delete.ts +1 -1
- package/src/ai/model-usage.ts +2 -1
- package/src/ai/open-questions.ts +1 -1
- package/src/ai/openai-api-slots.ts +2 -1
- package/src/ai/prefs.ts +2 -1
- package/src/ai/projects.ts +2 -1
- package/src/ai/provider-connections.ts +1 -1
- package/src/ai/quota-limits.ts +2 -1
- package/src/ai/quota-provider.ts +2 -1
- package/src/ai/quota-report.ts +2 -1
- package/src/ai/quotas-migrate.ts +2 -1
- package/src/ai/quotas.ts +2 -1
- package/src/ai/request-headers.ts +2 -1
- package/src/ai/review-content.ts +2 -1
- package/src/ai/skill-search.ts +2 -1
- package/src/ai/skills.ts +2 -1
- package/src/ai/store.ts +2 -1
- package/src/ai/structured-runs.ts +2 -1
- package/src/ai/tool-audit.ts +2 -1
- package/src/ai/turn-timing.ts +1 -1
- package/src/ai/usage.ts +2 -1
- package/src/ai/web-provenance.ts +2 -1
- package/src/api/admin-ai-quotas.ts +1 -1
- package/src/api/admin-ai.ts +1 -1
- package/src/api/admin-app-domains.ts +62 -0
- package/src/api/admin-app-keys.ts +47 -3
- package/src/api/admin-core-settings.ts +1 -1
- package/src/api/admin-outgoing-mail.ts +1 -1
- package/src/api/app-bootstrap.ts +153 -0
- package/src/api/app-events.ts +1 -1
- package/src/api/auth.ts +1 -1
- package/src/api/authenticate-app-request.ts +129 -0
- package/src/api/capabilities.ts +87 -6
- package/src/api/index.ts +7 -0
- package/src/api/me.ts +1 -1
- package/src/api/search.bench.ts +1 -1
- package/src/app-pwa/service.ts +2 -1
- package/src/app-pwa/transaction.ts +4 -2
- package/src/capabilities/claims.ts +2 -1
- package/src/capabilities/executions.ts +8 -4
- package/src/capabilities/geo-client.ts +1 -1
- package/src/capabilities/migrate.ts +3 -1
- package/src/capabilities/resource-readers.ts +2 -2
- package/src/capabilities/response.ts +2 -1
- package/src/capabilities/server-invoke.ts +73 -20
- package/src/capabilities/server.ts +2 -1
- package/src/cli/admin/app-domains.ts +140 -0
- package/src/cli/admin/app-keys.ts +25 -3
- package/src/cli/admin/index.ts +107 -88
- package/src/config/env.ts +10 -4
- package/src/contracts/app-domains.ts +91 -0
- package/src/contracts/app-status.ts +104 -0
- package/src/contracts/capabilities.ts +37 -2
- package/src/contracts/index.ts +1 -0
- package/src/contracts/outgoing-mail.ts +1 -1
- package/src/contracts/registry.ts +2 -0
- package/src/events/app-event-delivery.ts +1 -1
- package/src/events/live-delivery.ts +2 -1
- package/src/index.ts +1 -0
- package/src/runtime/bootstrap-client.ts +195 -0
- package/src/runtime/bootstrap-protocol.ts +67 -0
- package/src/runtime/clients.ts +6 -2
- package/src/runtime/connections.ts +9 -0
- package/src/server/app-key.ts +14 -0
- package/src/server/index.ts +2 -0
- package/src/server/ratelimit.ts +1 -1
- package/src/server/services/access.ts +2 -1
- package/src/services/account-category-policy.ts +1 -1
- package/src/services/account-lifecycle/audit.ts +1 -1
- package/src/services/account-lifecycle/index.ts +1 -1
- package/src/services/account-lifecycle/ipa-backfill.ts +2 -1
- package/src/services/accounts/app.ts +1 -1
- package/src/services/accounts/avatar.ts +1 -1
- package/src/services/accounts/base-group.ts +1 -1
- package/src/services/accounts/directory-shares.ts +1 -1
- package/src/services/accounts/duplicate-emails.ts +1 -1
- package/src/services/accounts/entities.ts +2 -1
- package/src/services/accounts/group-sql.ts +2 -1
- package/src/services/accounts/groups.ts +1 -1
- package/src/services/accounts/identities.ts +13 -11
- package/src/services/accounts/ipa-data.ts +1 -1
- package/src/services/accounts/local-groups.ts +2 -1
- package/src/services/accounts/machine-group-management.ts +2 -1
- package/src/services/accounts/posix.ts +13 -12
- package/src/services/accounts/request-policy.ts +2 -1
- package/src/services/accounts/switching.ts +1 -1
- package/src/services/accounts/users.ts +2 -1
- package/src/services/announcements/index.ts +1 -1
- package/src/services/app-access-management.ts +3 -9
- package/src/services/app-access.ts +2 -1
- package/src/services/app-admin-facts.ts +160 -0
- package/src/services/app-approval.ts +34 -30
- package/src/services/app-bootstrap.ts +217 -0
- package/src/services/app-details.ts +3 -2
- package/src/services/app-domain-check.ts +152 -0
- package/src/services/app-domains.ts +172 -0
- package/src/services/app-events/accept.ts +5 -5
- package/src/services/app-events/admin.ts +15 -15
- package/src/services/app-events/declarations.ts +6 -3
- package/src/services/app-events/deliver.ts +6 -6
- package/src/services/app-events.ts +3 -2
- package/src/services/app-keys.ts +45 -11
- package/src/services/approvals/content.ts +1 -1
- package/src/services/approvals/index.ts +2 -1
- package/src/services/approvals/list.ts +1 -1
- package/src/services/approvals/migrate.ts +1 -1
- package/src/services/audit/index.ts +2 -1
- package/src/services/auth-flows/ipa.ts +1 -1
- package/src/services/auth-flows/magic-link.ts +1 -1
- package/src/services/auth-flows/password-reset.ts +1 -1
- package/src/services/auth-flows/proxy-return.ts +1 -1
- package/src/services/downstream-budget.ts +2 -2
- package/src/services/geo/admin.ts +13 -12
- package/src/services/geo/identity.ts +46 -0
- package/src/services/geo/ledger.ts +118 -106
- package/src/services/geo/migrate.ts +2 -1
- package/src/services/geo/route-chain.ts +3 -3
- package/src/services/geo/service.ts +4 -24
- package/src/services/geoip/index.ts +5 -5
- package/src/services/geoip/store.ts +24 -21
- package/src/services/help/index.ts +2 -1
- package/src/services/help/store.ts +2 -1
- package/src/services/identity/app-request.ts +44 -0
- package/src/services/identity/constants.ts +1 -0
- package/src/services/identity/index.ts +1 -0
- package/src/services/identity/invocation-actor.ts +2 -1
- package/src/services/identity/invocation-token.ts +112 -0
- package/src/services/identity/key-ring.ts +2 -2
- package/src/services/identity/resource-readers.ts +1 -1
- package/src/services/identity/routes.ts +11 -3
- package/src/services/identity/runtime-config.ts +14 -3
- package/src/services/identity/workload-auth.ts +2 -0
- package/src/services/identity/workload-runtime.ts +3 -3
- package/src/services/index.ts +4 -0
- package/src/services/ipa/groups.ts +1 -1
- package/src/services/ipa/profile.ts +1 -1
- package/src/services/ipa/search.ts +1 -1
- package/src/services/ipa/sync.ts +1 -1
- package/src/services/ipa/users.ts +1 -1
- package/src/services/known-browsers.ts +2 -1
- package/src/services/legal-consent.ts +1 -1
- package/src/services/links/index.ts +16 -0
- package/src/services/links/runtime.ts +44 -0
- package/src/services/links/store.ts +105 -0
- package/src/services/logging/index.ts +13 -8
- package/src/services/logging/redaction.ts +6 -1
- package/src/services/logging/trace.ts +9 -9
- package/src/services/logging/web-vitals.ts +1 -1
- package/src/services/mandates/index.ts +2 -1
- package/src/services/notifications/batches.ts +1 -1
- package/src/services/notifications/browser.ts +1 -1
- package/src/services/notifications/catalog.ts +1 -1
- package/src/services/notifications/dispatcher.ts +1 -1
- package/src/services/notifications/index.ts +1 -1
- package/src/services/notifications/observability.ts +2 -1
- package/src/services/notifications/platform.ts +1 -1
- package/src/services/notifications/quiet.ts +1 -1
- package/src/services/notifications/user.ts +1 -1
- package/src/services/oauth-tokens.ts +1 -1
- package/src/services/outbox.ts +15 -14
- package/src/services/outgoing-mail/admin.ts +1 -1
- package/src/services/outgoing-mail/dispatcher.ts +1 -1
- package/src/services/outgoing-mail/drain.ts +2 -1
- package/src/services/outgoing-mail/enqueue.ts +2 -1
- package/src/services/outgoing-mail/messages.ts +2 -1
- package/src/services/outgoing-mail/retention.ts +1 -1
- package/src/services/outgoing-mail/send.ts +1 -1
- package/src/services/outgoing-mail/store.ts +2 -1
- package/src/services/panel-context-consent.ts +32 -28
- package/src/services/providers/local/auth.ts +1 -1
- package/src/services/providers/local/users.ts +1 -1
- package/src/services/pwa-devices.ts +2 -1
- package/src/services/rail-preferences.ts +16 -12
- package/src/services/rail-shortcuts.ts +12 -10
- package/src/services/request-cache-redis.ts +3 -3
- package/src/services/service-account-credentials.ts +2 -1
- package/src/services/service-accounts.ts +2 -1
- package/src/services/session/index.ts +2 -1
- package/src/services/session/user.ts +2 -1
- package/src/services/settings/core-settings.ts +20 -0
- package/src/services/settings/crypto.ts +2 -2
- package/src/services/settings/index.ts +1 -1
- package/src/services/settings/store.ts +6 -1
- package/src/services/settings/versions.ts +2 -1
- package/src/services/weather/forecast.ts +2 -1
- package/src/services/weather/locations.ts +1 -1
- package/src/services/weather/migrate.ts +2 -1
- package/src/services/weather/short-id.ts +2 -1
- package/src/services/webauthn.ts +1 -1
- package/src/ssr/admin-navigation.ts +0 -1
- package/src/ssr/compact-shell.browser-harness.ts +2 -0
- package/src/ssr/platform-messages.ts +0 -2
- package/src/start.ts +35 -14
- package/src/workflows/ai/store.ts +2 -1
- package/src/workflows/store/budget.ts +2 -1
- package/src/workflows/store/definitions.ts +2 -1
- package/src/workflows/store/events.ts +2 -1
- package/src/workflows/store/observability.ts +2 -1
- package/src/workflows/store/retention.ts +2 -1
- package/src/workflows/store/runs.ts +2 -1
- package/src/workflows/store/transaction.ts +2 -1
- package/src/workflows/store/worker.ts +2 -1
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { sql } from "bun";
|
|
2
1
|
import {
|
|
3
2
|
DEFAULT_LINUX_IDENTITY_CONFIGURATION,
|
|
4
3
|
isPosixName,
|
|
@@ -8,6 +7,7 @@ import {
|
|
|
8
7
|
PosixOverridesSchema,
|
|
9
8
|
} from "../../contracts/posix";
|
|
10
9
|
import type { MutationResult } from "../../contracts/shared";
|
|
10
|
+
import { sql } from "../../runtime/clients";
|
|
11
11
|
import { freeipa } from "../../server/services";
|
|
12
12
|
import { type AuditActor, audit } from "../audit";
|
|
13
13
|
import { getFreeIpaConfig } from "../freeipa-config";
|
|
@@ -128,8 +128,9 @@ const candidate = (row: IdentityRow): PosixCandidate => {
|
|
|
128
128
|
};
|
|
129
129
|
|
|
130
130
|
// Database injection is used by isolated integration tests; production uses the shared pool.
|
|
131
|
-
export const createPosixRuntime = (
|
|
132
|
-
const
|
|
131
|
+
export const createPosixRuntime = (injectedDb?: typeof sql, readRanges = readIpaIdRanges) => {
|
|
132
|
+
const db = () => injectedDb ?? sql;
|
|
133
|
+
const readConfig = async (connection = db()): Promise<LinuxIdentityConfiguration> => {
|
|
133
134
|
// Provisioning must not use a stale cache after an operator disables or changes the range.
|
|
134
135
|
const [row] = await connection<{ value: string }[]>`SELECT value FROM settings.entries WHERE key = ${CONFIG_KEY}`;
|
|
135
136
|
try {
|
|
@@ -256,7 +257,7 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
|
|
|
256
257
|
actor?: AuditActor,
|
|
257
258
|
): Promise<MutationResult<{ id: string }>> => {
|
|
258
259
|
try {
|
|
259
|
-
return await db.begin(async (tx) => {
|
|
260
|
+
return await db().begin(async (tx) => {
|
|
260
261
|
await lock(tx);
|
|
261
262
|
const result = await write(tx);
|
|
262
263
|
if (!result.ok) return result;
|
|
@@ -316,13 +317,13 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
|
|
|
316
317
|
},
|
|
317
318
|
async overview(actor: Actor, after: string | null = null, filters: { search?: string; scope?: "ready" | "all" } = {}) {
|
|
318
319
|
requireAdmin(actor);
|
|
319
|
-
const result = await rows(db, null, after, filters);
|
|
320
|
+
const result = await rows(db(), null, after, filters);
|
|
320
321
|
const items = result.slice(0, POSIX_PAGE_SIZE).map(candidate);
|
|
321
322
|
return { config: await service.configuration(actor), items, nextCursor: result.length > POSIX_PAGE_SIZE ? items.at(-1)!.id : null };
|
|
322
323
|
},
|
|
323
324
|
async get(actor: Actor, id: string) {
|
|
324
325
|
requireAdmin(actor);
|
|
325
|
-
const [row] = await rows(db, id, null);
|
|
326
|
+
const [row] = await rows(db(), id, null);
|
|
326
327
|
if (!row) throw new PosixError("user_not_found", 404);
|
|
327
328
|
return { config: await readConfig(), user: candidate(row) };
|
|
328
329
|
},
|
|
@@ -331,7 +332,7 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
|
|
|
331
332
|
const parsed = LinuxIdentityConfigurationSchema.safeParse(input);
|
|
332
333
|
if (!parsed.success) throw new PosixError("invalid_configuration", 400);
|
|
333
334
|
const ranges = parsed.data.enabled ? await readRanges() : [];
|
|
334
|
-
await db.begin(async (tx) => {
|
|
335
|
+
await db().begin(async (tx) => {
|
|
335
336
|
// Same order as provisioning; settings write upgrades this lock in the same transaction.
|
|
336
337
|
await lock(tx);
|
|
337
338
|
if (options?.replaceUnreadable !== true) {
|
|
@@ -360,7 +361,7 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
|
|
|
360
361
|
},
|
|
361
362
|
async provision(actor: Actor, id: string) {
|
|
362
363
|
requireAdmin(actor);
|
|
363
|
-
return db.begin(async (tx) => {
|
|
364
|
+
return db().begin(async (tx) => {
|
|
364
365
|
await lock(tx);
|
|
365
366
|
const config = await readConfig(tx);
|
|
366
367
|
if (!config.enabled) throw new PosixError("setup_disabled");
|
|
@@ -371,7 +372,7 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
|
|
|
371
372
|
requireAdmin(actor);
|
|
372
373
|
const parsed = PosixOverridesSchema.safeParse(input);
|
|
373
374
|
if (!parsed.success) throw new PosixError("invalid_paths", 400);
|
|
374
|
-
return db.begin(async (tx) => {
|
|
375
|
+
return db().begin(async (tx) => {
|
|
375
376
|
await lock(tx);
|
|
376
377
|
const [row] = await rows(tx, id, null);
|
|
377
378
|
if (!row) throw new PosixError("user_not_found", 404);
|
|
@@ -394,7 +395,7 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
|
|
|
394
395
|
async createGroup(actor: Actor, input: { name: string; description?: string }) {
|
|
395
396
|
requireAdmin(actor);
|
|
396
397
|
const ranges = await readRanges();
|
|
397
|
-
return db.begin(async (tx) => {
|
|
398
|
+
return db().begin(async (tx) => {
|
|
398
399
|
await lock(tx);
|
|
399
400
|
const created = await createLocalGroup(input, tx);
|
|
400
401
|
if (!created.ok) throw new PosixError("group_conflict");
|
|
@@ -405,7 +406,7 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
|
|
|
405
406
|
async provisionGroup(actor: Actor, id: string) {
|
|
406
407
|
requireAdmin(actor);
|
|
407
408
|
const ranges = await readRanges();
|
|
408
|
-
return db.begin(async (tx) => {
|
|
409
|
+
return db().begin(async (tx) => {
|
|
409
410
|
await lock(tx);
|
|
410
411
|
return assignGroup(tx, actor, id, ranges);
|
|
411
412
|
});
|
|
@@ -413,5 +414,5 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
|
|
|
413
414
|
};
|
|
414
415
|
return { service, writeLocalAccount };
|
|
415
416
|
};
|
|
416
|
-
export const createPosixService = (db
|
|
417
|
+
export const createPosixService = (db?: typeof sql, readRanges = readIpaIdRanges) => createPosixRuntime(db, readRanges).service;
|
|
417
418
|
export const { service: posix, writeLocalAccount } = createPosixRuntime();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
2
|
import type {
|
|
3
3
|
BaseUser,
|
|
4
4
|
MutationResult,
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
UserProfile,
|
|
10
10
|
UserProvider,
|
|
11
11
|
} from "../../contracts/shared";
|
|
12
|
+
import { sql } from "../../runtime/clients";
|
|
12
13
|
import { freeipa } from "../../server/services";
|
|
13
14
|
import { createAuthLoginUrl } from "../../shared/redirect";
|
|
14
15
|
import { isAccountCategoryAllowed } from "../account-category-policy";
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { err, fail, ok, type Result } from "@k2b/stdlib";
|
|
2
|
-
import { sql } from "bun";
|
|
3
2
|
import { HTTPException } from "hono/http-exception";
|
|
4
3
|
import { z } from "zod";
|
|
5
4
|
import type {
|
|
@@ -11,6 +10,7 @@ import type {
|
|
|
11
10
|
} from "../../contracts/announcements";
|
|
12
11
|
import { AnnouncementDisplayEntrySchema } from "../../contracts/announcements";
|
|
13
12
|
import { hasRole, type User } from "../../contracts/shared";
|
|
13
|
+
import { sql } from "../../runtime/clients";
|
|
14
14
|
import { markdown } from "../../shared/markdown";
|
|
15
15
|
import { claimCacheFill, completeCacheFill } from "../cache-fill";
|
|
16
16
|
import { logger } from "../logging";
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import { managerEligibility } from "../_internal/app-access-manager-sql";
|
|
2
3
|
import type { AppMeta } from "../contracts/app";
|
|
3
4
|
import type { AppAccessDeclaration, AppAccessSnapshot } from "../contracts/app-access";
|
|
4
5
|
import { publishAppAccessChanged, wakeAppAccessChanged } from "../events/app-access";
|
|
6
|
+
import { sql } from "../runtime/clients";
|
|
5
7
|
import { isAppSessionActor, userFromActor } from "../server/actor";
|
|
6
8
|
import type { RequestAuthority } from "../server/middleware/auth";
|
|
7
9
|
import {
|
|
@@ -107,14 +109,6 @@ const listEntries = async (appId: string, db: SQL = sql): Promise<AccessEntry[]>
|
|
|
107
109
|
: { type: "public" },
|
|
108
110
|
}));
|
|
109
111
|
};
|
|
110
|
-
/** Shared eligibility predicate for the last-manager guard and its presentation. */
|
|
111
|
-
const managerEligibility = (db: SQL) => db`
|
|
112
|
-
EXISTS (SELECT 1 FROM auth.users u WHERE (u.account_expires IS NULL OR u.account_expires > now()) AND
|
|
113
|
-
(u.id = a.user_id OR (a.group_id IS NOT NULL AND a.group_id IN (${recursiveGroupIdsSubquery(db`u.id`)}))))
|
|
114
|
-
OR EXISTS (SELECT 1 FROM auth.service_accounts sa WHERE sa.id = a.service_account_id
|
|
115
|
-
AND sa.status = 'active' AND sa.kind IN ('standalone', 'agent'))
|
|
116
|
-
`;
|
|
117
|
-
|
|
118
112
|
const entryPresentation = async (appId: string): Promise<Map<string, { groupMemberCount?: number; effectiveManager: boolean }>> => {
|
|
119
113
|
const rows = await sql<{ id: string; group_member_count: number | null; effective_manager: boolean }[]>`
|
|
120
114
|
SELECT a.id,
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
2
|
import type { Context } from "hono";
|
|
3
3
|
import { RESOURCE_ACCESS_TIMEOUT_MS, resourceAppAccess } from "../_internal/app-access-resources";
|
|
4
4
|
import type { AppMeta } from "../contracts/app";
|
|
5
5
|
import { AppAccessRuleSchema, type FixedAppAccessRule } from "../contracts/app-access";
|
|
6
|
+
import { sql } from "../runtime/clients";
|
|
6
7
|
import { userFromActor } from "../server/actor";
|
|
7
8
|
import { type AuthContext, auth, type RequestAuthority } from "../server/middleware/auth";
|
|
8
9
|
import { buildAccessPrincipalCondition } from "../server/services/access";
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { managerEligibility } from "../_internal/app-access-manager-sql";
|
|
2
|
+
import { sql } from "../runtime/clients";
|
|
3
|
+
import type { RequestAuthority } from "../server/middleware/auth";
|
|
4
|
+
import { AppAccessManagementError, canRepairAppAccess } from "./app-access-management";
|
|
5
|
+
|
|
6
|
+
export type AppAdminFacts = {
|
|
7
|
+
access: { people: number; groups: number; serviceAccounts: number; managers: number; configured: boolean } | null;
|
|
8
|
+
keys: {
|
|
9
|
+
active: number;
|
|
10
|
+
expired: number;
|
|
11
|
+
revoked: number;
|
|
12
|
+
lastUsedAt: string | null;
|
|
13
|
+
firstCreatedAt: string | null;
|
|
14
|
+
accountStatus: "active" | "disabled" | null;
|
|
15
|
+
};
|
|
16
|
+
tokens: { active: number; total: number };
|
|
17
|
+
events: { hears: number; sends: number | null; failedDeliveries: number; waitingDeliveries: number };
|
|
18
|
+
notifications: number | null;
|
|
19
|
+
firstKnownAt: string | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Core-owned administration summary: at most 500 input IDs and four batched SQL reads. */
|
|
23
|
+
export const appAdminFacts = async (authority: RequestAuthority, appIds: readonly string[]): Promise<Map<string, AppAdminFacts>> => {
|
|
24
|
+
if (!canRepairAppAccess(authority)) throw new AppAccessManagementError("FORBIDDEN", "Cloud administration authority is required.");
|
|
25
|
+
if (appIds.length > 500) throw new AppAccessManagementError("TOO_MANY_APPS", "At most 500 application IDs may be read at once.", 400);
|
|
26
|
+
const ids = [...new Set(appIds)];
|
|
27
|
+
const facts = new Map<string, AppAdminFacts>(
|
|
28
|
+
ids.map((id) => [
|
|
29
|
+
id,
|
|
30
|
+
{
|
|
31
|
+
access: null,
|
|
32
|
+
keys: { active: 0, expired: 0, revoked: 0, lastUsedAt: null, firstCreatedAt: null, accountStatus: null },
|
|
33
|
+
tokens: { active: 0, total: 0 },
|
|
34
|
+
events: { hears: 0, sends: 0, failedDeliveries: 0, waitingDeliveries: 0 },
|
|
35
|
+
notifications: 0,
|
|
36
|
+
firstKnownAt: null,
|
|
37
|
+
},
|
|
38
|
+
]),
|
|
39
|
+
);
|
|
40
|
+
if (!ids.length) return facts;
|
|
41
|
+
|
|
42
|
+
const [access, credentials, events, notifications] = await Promise.all([
|
|
43
|
+
sql<
|
|
44
|
+
{
|
|
45
|
+
app_id: string;
|
|
46
|
+
people: number;
|
|
47
|
+
groups: number;
|
|
48
|
+
service_accounts: number;
|
|
49
|
+
managers: number;
|
|
50
|
+
configured: boolean;
|
|
51
|
+
first_known_at: Date | null;
|
|
52
|
+
}[]
|
|
53
|
+
>`
|
|
54
|
+
SELECT COALESCE(aa.app_id, s.app_id) AS app_id,
|
|
55
|
+
count(a.user_id)::int AS people, count(a.group_id)::int AS groups,
|
|
56
|
+
count(a.service_account_id)::int AS service_accounts,
|
|
57
|
+
count(*) FILTER (WHERE a.permission = 'admin' AND (${managerEligibility(sql)}))::int AS managers,
|
|
58
|
+
s.configured_at IS NOT NULL AS configured,
|
|
59
|
+
LEAST(min(a.created_at), s.configured_at) AS first_known_at
|
|
60
|
+
FROM auth.app_access aa
|
|
61
|
+
FULL JOIN auth.app_access_state s ON s.app_id = aa.app_id
|
|
62
|
+
LEFT JOIN auth.access a ON a.id = aa.access_id
|
|
63
|
+
WHERE COALESCE(aa.app_id, s.app_id) IN ${sql(ids)}
|
|
64
|
+
GROUP BY aa.app_id, s.app_id, s.configured_at
|
|
65
|
+
`,
|
|
66
|
+
sql<
|
|
67
|
+
{
|
|
68
|
+
app_id: string;
|
|
69
|
+
active: number;
|
|
70
|
+
expired: number;
|
|
71
|
+
revoked: number;
|
|
72
|
+
last_used_at: Date | null;
|
|
73
|
+
first_created_at: Date | null;
|
|
74
|
+
account_status: "active" | "disabled";
|
|
75
|
+
active_tokens: number;
|
|
76
|
+
total_tokens: number;
|
|
77
|
+
first_known_at: Date;
|
|
78
|
+
}[]
|
|
79
|
+
>`
|
|
80
|
+
WITH accounts AS (
|
|
81
|
+
SELECT id, app_id, status, created_at FROM auth.service_accounts
|
|
82
|
+
WHERE kind = 'resource_bound' AND resource_type = 'cloud.app' AND resource_id = app_id
|
|
83
|
+
AND app_id IN ${sql(ids)}
|
|
84
|
+
), canonical_accounts AS (
|
|
85
|
+
SELECT DISTINCT ON (app_id) app_id, status FROM accounts ORDER BY app_id, created_at, id
|
|
86
|
+
)
|
|
87
|
+
SELECT sa.app_id,
|
|
88
|
+
count(*) FILTER (WHERE c.kind = 'app_key' AND c.status = 'active'
|
|
89
|
+
AND (c.expires_at IS NULL OR c.expires_at > now()))::int AS active,
|
|
90
|
+
count(*) FILTER (WHERE c.kind = 'app_key' AND c.status <> 'revoked' AND c.expires_at <= now())::int AS expired,
|
|
91
|
+
count(*) FILTER (WHERE c.kind = 'app_key' AND c.status = 'revoked')::int AS revoked,
|
|
92
|
+
max(c.last_used_at) FILTER (WHERE c.kind = 'app_key') AS last_used_at,
|
|
93
|
+
min(c.created_at) FILTER (WHERE c.kind = 'app_key') AS first_created_at,
|
|
94
|
+
canonical.status AS account_status,
|
|
95
|
+
count(*) FILTER (WHERE c.kind <> 'app_key' AND c.status = 'active'
|
|
96
|
+
AND (c.expires_at IS NULL OR c.expires_at > now()))::int AS active_tokens,
|
|
97
|
+
count(*) FILTER (WHERE c.kind <> 'app_key')::int AS total_tokens,
|
|
98
|
+
LEAST(min(sa.created_at), min(c.created_at)) AS first_known_at
|
|
99
|
+
FROM accounts sa
|
|
100
|
+
JOIN canonical_accounts canonical ON canonical.app_id = sa.app_id
|
|
101
|
+
LEFT JOIN auth.service_account_credentials c ON c.service_account_id = sa.id
|
|
102
|
+
GROUP BY sa.app_id, canonical.status
|
|
103
|
+
`,
|
|
104
|
+
sql<{ app_id: string; hears: number; sends: number; failed: number; waiting: number }[]>`
|
|
105
|
+
WITH subscriptions AS (
|
|
106
|
+
SELECT app_id, count(*) FILTER (WHERE state = 'active')::int AS hears
|
|
107
|
+
FROM events.app_subscriptions WHERE app_id IN ${sql(ids)} GROUP BY app_id
|
|
108
|
+
), declarations AS (
|
|
109
|
+
SELECT app_id, count(*)::int AS sends
|
|
110
|
+
FROM events.app_declarations WHERE app_id IN ${sql(ids)} AND retired_at IS NULL GROUP BY app_id
|
|
111
|
+
), deliveries AS (
|
|
112
|
+
SELECT subscription.app_id, count(*) FILTER (WHERE delivery.state = 'failed')::int AS failed,
|
|
113
|
+
count(*) FILTER (WHERE delivery.state IN ('pending', 'retrying'))::int AS waiting
|
|
114
|
+
FROM events.app_subscriptions subscription
|
|
115
|
+
JOIN events.app_deliveries delivery ON delivery.subscription_id = subscription.id
|
|
116
|
+
WHERE subscription.app_id IN ${sql(ids)} AND delivery.state IN ('failed', 'pending', 'retrying')
|
|
117
|
+
GROUP BY subscription.app_id
|
|
118
|
+
)
|
|
119
|
+
SELECT COALESCE(s.app_id, d.app_id, f.app_id) AS app_id, COALESCE(s.hears, 0) AS hears,
|
|
120
|
+
COALESCE(d.sends, 0) AS sends, COALESCE(f.failed, 0) AS failed, COALESCE(f.waiting, 0) AS waiting
|
|
121
|
+
FROM subscriptions s FULL JOIN declarations d ON d.app_id = s.app_id
|
|
122
|
+
FULL JOIN deliveries f ON f.app_id = COALESCE(s.app_id, d.app_id)
|
|
123
|
+
`,
|
|
124
|
+
sql<{ app_id: string; count: number }[]>`
|
|
125
|
+
SELECT app_id, count(*)::int AS count FROM notifications.definitions
|
|
126
|
+
WHERE app_id IN ${sql(ids)} AND active = true GROUP BY app_id
|
|
127
|
+
`,
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
for (const row of access) {
|
|
131
|
+
const fact = facts.get(row.app_id)!;
|
|
132
|
+
fact.access = {
|
|
133
|
+
people: row.people,
|
|
134
|
+
groups: row.groups,
|
|
135
|
+
serviceAccounts: row.service_accounts,
|
|
136
|
+
managers: row.managers,
|
|
137
|
+
configured: row.configured,
|
|
138
|
+
};
|
|
139
|
+
fact.firstKnownAt = row.first_known_at?.toISOString() ?? null;
|
|
140
|
+
}
|
|
141
|
+
for (const row of credentials) {
|
|
142
|
+
const fact = facts.get(row.app_id)!;
|
|
143
|
+
fact.keys = {
|
|
144
|
+
active: row.active,
|
|
145
|
+
expired: row.expired,
|
|
146
|
+
revoked: row.revoked,
|
|
147
|
+
lastUsedAt: row.last_used_at?.toISOString() ?? null,
|
|
148
|
+
firstCreatedAt: row.first_created_at?.toISOString() ?? null,
|
|
149
|
+
accountStatus: row.account_status,
|
|
150
|
+
};
|
|
151
|
+
fact.tokens = { active: row.active_tokens, total: row.total_tokens };
|
|
152
|
+
const firstKnownAt = row.first_known_at.toISOString();
|
|
153
|
+
if (fact.firstKnownAt === null || firstKnownAt < fact.firstKnownAt) fact.firstKnownAt = firstKnownAt;
|
|
154
|
+
}
|
|
155
|
+
for (const row of events) {
|
|
156
|
+
facts.get(row.app_id)!.events = { hears: row.hears, sends: row.sends, failedDeliveries: row.failed, waitingDeliveries: row.waiting };
|
|
157
|
+
}
|
|
158
|
+
for (const row of notifications) facts.get(row.app_id)!.notifications = row.count;
|
|
159
|
+
return facts;
|
|
160
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
2
|
import type { z } from "zod";
|
|
3
3
|
import { lazySync } from "../_internal/process-sync";
|
|
4
4
|
import { env } from "../config/env";
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
appPairingProofMessage,
|
|
26
26
|
APP_APPROVAL_LIMITS as limits,
|
|
27
27
|
} from "../contracts/app-approval";
|
|
28
|
+
import { sql } from "../runtime/clients";
|
|
28
29
|
import { addressPrefix } from "../server/request-context/address";
|
|
29
30
|
import { publicCloudOrigin } from "../shared/app-url";
|
|
30
31
|
import { isAccountCategoryAllowed } from "./account-category-policy";
|
|
@@ -296,12 +297,13 @@ const verify = async (key: AppDevicePublicKey, message: string, signature: strin
|
|
|
296
297
|
/** One owner for production API and isolated HTTP consumers. Config is re-read
|
|
297
298
|
* per operation; SQL transactions serialize device decisions and revocation. */
|
|
298
299
|
export const createAppApprovalService = (
|
|
299
|
-
|
|
300
|
-
configuration = (requireEnabled = true) => readAppApprovalConfig(
|
|
300
|
+
injectedDb?: SQL,
|
|
301
|
+
configuration = (requireEnabled = true) => readAppApprovalConfig(injectedDb ?? sql, requireEnabled),
|
|
301
302
|
hints: AppLoginDecisionHints = syncLoginDecisionHints,
|
|
302
303
|
push: AppLoginPushSender = fetchPushSender,
|
|
303
304
|
addresses: AppLoginAddressStore = valkeyAppLoginAddressStore,
|
|
304
305
|
) => {
|
|
306
|
+
const db = () => injectedDb ?? sql;
|
|
305
307
|
// Error messages may contain their inputs; log only the error class, never addresses or proofs.
|
|
306
308
|
const addressFailure = (error: unknown) =>
|
|
307
309
|
log.warn("App login address hint unavailable", { error: error instanceof Error ? error.name : "UnknownError" });
|
|
@@ -332,12 +334,12 @@ export const createAppApprovalService = (
|
|
|
332
334
|
const cleanup = async () => {
|
|
333
335
|
// Bounded opportunistic maintenance: each accepted write retires up to 100
|
|
334
336
|
// expired rows per table, so bursts cannot trigger an unbounded sweep.
|
|
335
|
-
await db`DELETE FROM auth.app_device_changes WHERE id IN (SELECT id FROM auth.app_device_changes WHERE expires_at < now() - interval '1 minute' ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
336
|
-
await db`DELETE FROM auth.app_pairings WHERE id IN (SELECT p.id FROM auth.app_pairings p
|
|
337
|
+
await db()`DELETE FROM auth.app_device_changes WHERE id IN (SELECT id FROM auth.app_device_changes WHERE expires_at < now() - interval '1 minute' ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
338
|
+
await db()`DELETE FROM auth.app_pairings WHERE id IN (SELECT p.id FROM auth.app_pairings p
|
|
337
339
|
WHERE p.expires_at < now() - interval '1 minute' AND NOT EXISTS (SELECT 1 FROM auth.app_device_changes c WHERE c.pairing_id=p.id)
|
|
338
340
|
ORDER BY p.expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
339
|
-
await db`DELETE FROM auth.app_logins WHERE id IN (SELECT id FROM auth.app_logins WHERE expires_at < now() AND NOT (kind='terminal' AND state='approved') ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
340
|
-
await db`DELETE FROM auth.app_device_proofs WHERE (device_id,jti) IN (SELECT device_id,jti FROM auth.app_device_proofs WHERE expires_at < now() ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
341
|
+
await db()`DELETE FROM auth.app_logins WHERE id IN (SELECT id FROM auth.app_logins WHERE expires_at < now() AND NOT (kind='terminal' AND state='approved') ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
342
|
+
await db()`DELETE FROM auth.app_device_proofs WHERE (device_id,jti) IN (SELECT device_id,jti FROM auth.app_device_proofs WHERE expires_at < now() ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
341
343
|
};
|
|
342
344
|
const pairingOwner = async (tx: SQL, actor: AppApprovalActor, pairing: PairingRow, cfg: AppApprovalConfig) => {
|
|
343
345
|
if (
|
|
@@ -409,7 +411,7 @@ export const createAppApprovalService = (
|
|
|
409
411
|
if (!cfg.enabled || !cfg.appOrigin) return;
|
|
410
412
|
try {
|
|
411
413
|
// A withdrawn device change keeps its window, so withdrawing and asking again cannot push without bound.
|
|
412
|
-
const rows = await db<{ push_token: string }[]>`
|
|
414
|
+
const rows = await db()<{ push_token: string }[]>`
|
|
413
415
|
UPDATE auth.app_devices d SET login_wake_at=now(), login_wake_ref=${requestId}::uuid
|
|
414
416
|
WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND revoked_at IS NULL AND push_token IS NOT NULL
|
|
415
417
|
AND (${options?.accept ?? null}::text IS NULL OR ${options?.accept ?? null}::text=ANY(d.accepts))
|
|
@@ -423,7 +425,7 @@ export const createAppApprovalService = (
|
|
|
423
425
|
Array.from(new Set(rows.map((row) => row.push_token)), async (token) => {
|
|
424
426
|
const status = await push(cfg.appOrigin, { token, cloudOrigin: cfg.issuer, requestRef: requestId });
|
|
425
427
|
if (status === 410)
|
|
426
|
-
await db`UPDATE auth.app_devices SET push_token=NULL WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND push_token=${token}`;
|
|
428
|
+
await db()`UPDATE auth.app_devices SET push_token=NULL WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND push_token=${token}`;
|
|
427
429
|
}),
|
|
428
430
|
);
|
|
429
431
|
} catch (error) {
|
|
@@ -463,7 +465,7 @@ export const createAppApprovalService = (
|
|
|
463
465
|
try {
|
|
464
466
|
const cfg = await config(false);
|
|
465
467
|
if (!cfg.enabled || !cfg.appOrigin) return;
|
|
466
|
-
const rows = await db<{ push_token: string; user_id: string }[]>`
|
|
468
|
+
const rows = await db()<{ push_token: string; user_id: string }[]>`
|
|
467
469
|
UPDATE auth.app_devices d SET action_wake_at=now(), action_wake_ref=${approvalId}::uuid
|
|
468
470
|
WHERE d.id IN (
|
|
469
471
|
SELECT device.id FROM auth.app_devices device JOIN approvals.requests r ON r.approver_user_id=device.user_id
|
|
@@ -483,7 +485,7 @@ export const createAppApprovalService = (
|
|
|
483
485
|
Array.from(tokens, async ([token, userId]) => {
|
|
484
486
|
const status = await push(cfg.appOrigin, { token, cloudOrigin: cfg.issuer, requestRef: approvalId, kind: "action" });
|
|
485
487
|
if (status === 410)
|
|
486
|
-
await db`UPDATE auth.app_devices SET push_token=NULL WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND push_token=${token}`;
|
|
488
|
+
await db()`UPDATE auth.app_devices SET push_token=NULL WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND push_token=${token}`;
|
|
487
489
|
}),
|
|
488
490
|
);
|
|
489
491
|
} catch (error) {
|
|
@@ -492,7 +494,7 @@ export const createAppApprovalService = (
|
|
|
492
494
|
},
|
|
493
495
|
maintain: async (notify: (notice: AppDeviceEnrollmentNotice) => Promise<unknown>, signal?: AbortSignal) => {
|
|
494
496
|
await cleanup();
|
|
495
|
-
const rows = await db<(DeviceRow & { has_email: boolean })[]>`
|
|
497
|
+
const rows = await db()<(DeviceRow & { has_email: boolean })[]>`
|
|
496
498
|
SELECT d.*, COALESCE(btrim(u.mail), '') <> '' AS has_email
|
|
497
499
|
FROM auth.app_devices d JOIN auth.users u ON u.id = d.user_id
|
|
498
500
|
WHERE d.notified_at IS NULL ORDER BY d.created_at LIMIT ${limits.pageSize}`;
|
|
@@ -503,7 +505,7 @@ export const createAppApprovalService = (
|
|
|
503
505
|
// The notice is email-only, so an account without email relies on the
|
|
504
506
|
// enrollment audit entry instead of a delivery that can only fail.
|
|
505
507
|
if (row.has_email) await notify({ deviceId: row.id, userId: row.user_id, name: row.name, assisted: row.assisted });
|
|
506
|
-
await db`UPDATE auth.app_devices SET notified_at=now() WHERE id=${row.id}::uuid AND notified_at IS NULL`;
|
|
508
|
+
await db()`UPDATE auth.app_devices SET notified_at=now() WHERE id=${row.id}::uuid AND notified_at IS NULL`;
|
|
507
509
|
}
|
|
508
510
|
},
|
|
509
511
|
info: async () => {
|
|
@@ -524,7 +526,7 @@ export const createAppApprovalService = (
|
|
|
524
526
|
const id = crypto.randomUUID(),
|
|
525
527
|
token = secret(),
|
|
526
528
|
expiresAt = future(limits.pairingSeconds);
|
|
527
|
-
return db.begin(async (tx) => {
|
|
529
|
+
return db().begin(async (tx) => {
|
|
528
530
|
await fresh(tx, actor);
|
|
529
531
|
if (targetId !== actor.userId && (!actor.admin || !cfg.adminPairing)) return reject("FORBIDDEN", 403);
|
|
530
532
|
await tx`SELECT id FROM auth.users WHERE id = ${targetId}::uuid FOR UPDATE`;
|
|
@@ -549,7 +551,7 @@ export const createAppApprovalService = (
|
|
|
549
551
|
claimPairing: async (claim: { pairingId: string; secret: string; publicKey: AppDevicePublicKey; name: string; signature: string }) => {
|
|
550
552
|
const cfg = await config();
|
|
551
553
|
if (!(await verify(claim.publicKey, appPairingProofMessage(cfg.issuer, claim), claim.signature))) return reject("FORBIDDEN", 403);
|
|
552
|
-
return db.begin(async (tx) => {
|
|
554
|
+
return db().begin(async (tx) => {
|
|
553
555
|
const [p] = await tx<
|
|
554
556
|
PairingRow[]
|
|
555
557
|
>`SELECT * FROM auth.app_pairings WHERE id = ${claim.pairingId}::uuid AND issuer = ${cfg.issuer} FOR UPDATE`;
|
|
@@ -564,7 +566,7 @@ export const createAppApprovalService = (
|
|
|
564
566
|
},
|
|
565
567
|
pairingResult: async (id: string, token: string, key: AppDevicePublicKey) => {
|
|
566
568
|
const cfg = await config();
|
|
567
|
-
const [p] = await db<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id = ${id}::uuid AND issuer = ${cfg.issuer}`;
|
|
569
|
+
const [p] = await db()<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id = ${id}::uuid AND issuer = ${cfg.issuer}`;
|
|
568
570
|
if (!p || !matches(token, p.secret_hash)) return reject("FORBIDDEN", 403);
|
|
569
571
|
const age = Date.now() - new Date(p.expires_at).getTime();
|
|
570
572
|
if (age >= 0 && !(age < 60_000 && (p.state === "confirmed" || p.state === "cancelled"))) return reject("FORBIDDEN", 403);
|
|
@@ -576,7 +578,7 @@ export const createAppApprovalService = (
|
|
|
576
578
|
},
|
|
577
579
|
inspectPairing: async (actor: AppApprovalActor, id: string) => {
|
|
578
580
|
const cfg = await config();
|
|
579
|
-
return db.begin(async (tx) => {
|
|
581
|
+
return db().begin(async (tx) => {
|
|
580
582
|
const [p] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id = ${id}::uuid AND issuer = ${cfg.issuer}`;
|
|
581
583
|
if (!p) return reject("UNAVAILABLE", 404);
|
|
582
584
|
const [change] = await tx<ChangeRow[]>`SELECT * FROM auth.app_device_changes WHERE pairing_id=${p.id}::uuid`;
|
|
@@ -598,7 +600,7 @@ export const createAppApprovalService = (
|
|
|
598
600
|
confirmPairing: async (actor: AppApprovalActor, id: string, code: string): Promise<z.infer<typeof AppPairingConfirmResultSchema>> => {
|
|
599
601
|
const cfg = await config();
|
|
600
602
|
let wakeId: string | undefined;
|
|
601
|
-
const result = await db.begin(async (tx) => {
|
|
603
|
+
const result = await db().begin(async (tx) => {
|
|
602
604
|
const [snapshot] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id=${id}::uuid AND issuer=${cfg.issuer}`;
|
|
603
605
|
if (!snapshot) return reject("CONFLICT", 409);
|
|
604
606
|
await tx`SELECT id FROM auth.users WHERE id=${snapshot.user_id}::uuid FOR NO KEY UPDATE`;
|
|
@@ -636,7 +638,7 @@ export const createAppApprovalService = (
|
|
|
636
638
|
},
|
|
637
639
|
cancelPairing: async (actor: AppApprovalActor, id: string) => {
|
|
638
640
|
const cfg = await config();
|
|
639
|
-
return db.begin(async (tx) => {
|
|
641
|
+
return db().begin(async (tx) => {
|
|
640
642
|
const [snapshot] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id=${id}::uuid AND issuer=${cfg.issuer}`;
|
|
641
643
|
if (!snapshot) return reject("UNAVAILABLE", 404);
|
|
642
644
|
await tx`SELECT id FROM auth.users WHERE id=${snapshot.user_id}::uuid FOR NO KEY UPDATE`;
|
|
@@ -656,7 +658,7 @@ export const createAppApprovalService = (
|
|
|
656
658
|
const cfg = await config(false);
|
|
657
659
|
const changeId = "changeId" in lookup ? lookup.changeId : null,
|
|
658
660
|
deviceId = "deviceId" in lookup ? lookup.deviceId : null;
|
|
659
|
-
const [row] = await db<ChangeRow[]>`SELECT * FROM auth.app_device_changes
|
|
661
|
+
const [row] = await db()<ChangeRow[]>`SELECT * FROM auth.app_device_changes
|
|
660
662
|
WHERE issuer=${cfg.issuer} AND user_id=${actor.userId}::uuid AND requested_by_sid=${actor.sid}::uuid AND kind='revoke'
|
|
661
663
|
AND (${changeId}::uuid IS NULL OR id=${changeId}::uuid) AND (${deviceId}::uuid IS NULL OR device_id=${deviceId}::uuid)
|
|
662
664
|
ORDER BY created_at DESC, id DESC LIMIT 1`;
|
|
@@ -665,7 +667,7 @@ export const createAppApprovalService = (
|
|
|
665
667
|
},
|
|
666
668
|
cancelChange: async (actor: AppApprovalActor, id: string) => {
|
|
667
669
|
const cfg = await config(false);
|
|
668
|
-
return db.begin(async (tx) => {
|
|
670
|
+
return db().begin(async (tx) => {
|
|
669
671
|
await tx`SELECT id FROM auth.users WHERE id=${actor.userId}::uuid FOR NO KEY UPDATE`;
|
|
670
672
|
const change = await ownedChange(tx, cfg, actor, id);
|
|
671
673
|
if (changeStatus(change).state !== "pending") return reject("CONFLICT", 409);
|
|
@@ -676,7 +678,7 @@ export const createAppApprovalService = (
|
|
|
676
678
|
},
|
|
677
679
|
listDevices: async (actor: AppApprovalActor, after?: string) => {
|
|
678
680
|
const cfg = await config(false);
|
|
679
|
-
const rows = await db<
|
|
681
|
+
const rows = await db()<
|
|
680
682
|
DeviceRow[]
|
|
681
683
|
>`SELECT * FROM auth.app_devices WHERE issuer=${cfg.issuer} AND user_id=${actor.userId}::uuid AND (${after ?? null}::uuid IS NULL OR id > ${after ?? null}::uuid) ORDER BY id LIMIT ${limits.pageSize + 1}`;
|
|
682
684
|
return {
|
|
@@ -692,7 +694,7 @@ export const createAppApprovalService = (
|
|
|
692
694
|
): Promise<z.infer<typeof AppDeviceRevokeResultSchema> | undefined> => {
|
|
693
695
|
const cfg = await config(false);
|
|
694
696
|
let wakeId: string | undefined;
|
|
695
|
-
const result = await db.begin(async (tx) => {
|
|
697
|
+
const result = await db().begin(async (tx) => {
|
|
696
698
|
await tx`SELECT id FROM auth.users WHERE id=${actor.userId}::uuid FOR NO KEY UPDATE`;
|
|
697
699
|
const user = await fresh(tx, actor);
|
|
698
700
|
const [device] = await tx<DeviceRow[]>`SELECT * FROM auth.app_devices
|
|
@@ -723,7 +725,7 @@ export const createAppApprovalService = (
|
|
|
723
725
|
listUserDevices: async (actor: AppDeviceAdministrator, userId: string): Promise<AppDeviceView[]> => {
|
|
724
726
|
if (!actor.admin) return reject("FORBIDDEN", 403);
|
|
725
727
|
const cfg = await config(false);
|
|
726
|
-
const rows = await db<
|
|
728
|
+
const rows = await db()<
|
|
727
729
|
DeviceRow[]
|
|
728
730
|
>`SELECT * FROM auth.app_devices WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND revoked_at IS NULL ORDER BY created_at, id LIMIT ${limits.devicesPerAccount}`;
|
|
729
731
|
return rows.map(view);
|
|
@@ -733,7 +735,7 @@ export const createAppApprovalService = (
|
|
|
733
735
|
revokeUserDevice: async (actor: AppDeviceAdministrator, userId: string, id: string) => {
|
|
734
736
|
if (!actor.admin) return reject("FORBIDDEN", 403);
|
|
735
737
|
const cfg = await config(false);
|
|
736
|
-
return db.begin(async (tx) => {
|
|
738
|
+
return db().begin(async (tx) => {
|
|
737
739
|
const [device] = await tx<
|
|
738
740
|
DeviceRow[]
|
|
739
741
|
>`SELECT * FROM auth.app_devices WHERE id=${id}::uuid AND issuer=${cfg.issuer} AND user_id=${userId}::uuid FOR UPDATE`;
|
|
@@ -753,7 +755,7 @@ export const createAppApprovalService = (
|
|
|
753
755
|
code = comparison(),
|
|
754
756
|
expiresAt = future(limits.loginSeconds);
|
|
755
757
|
let owner: string | undefined;
|
|
756
|
-
await db.begin(async (tx) => {
|
|
758
|
+
await db().begin(async (tx) => {
|
|
757
759
|
let candidate = await loginCandidate(tx, identifier, category);
|
|
758
760
|
if (candidate && !(await loginBudget(tx, cfg.issuer, candidate.id))) candidate = undefined;
|
|
759
761
|
const now = new Date();
|
|
@@ -789,7 +791,7 @@ export const createAppApprovalService = (
|
|
|
789
791
|
code = comparison(),
|
|
790
792
|
expiresAt = future(limits.loginSeconds);
|
|
791
793
|
let owner: string | undefined;
|
|
792
|
-
await db.begin(async (tx) => {
|
|
794
|
+
await db().begin(async (tx) => {
|
|
793
795
|
let candidate = await loginCandidate(tx, input.identifier);
|
|
794
796
|
const subject = candidate ?? { id: crypto.randomUUID(), profile: "user" as const };
|
|
795
797
|
// Even a decoy performs the client-access, device and shared-budget queries.
|
|
@@ -830,7 +832,7 @@ export const createAppApprovalService = (
|
|
|
830
832
|
return reject("FORBIDDEN", 403);
|
|
831
833
|
await cleanup();
|
|
832
834
|
let decidedBrowserId: string | undefined;
|
|
833
|
-
const outcome = await db.begin(async (tx) => {
|
|
835
|
+
const outcome = await db().begin(async (tx) => {
|
|
834
836
|
let changeUserId: string | undefined;
|
|
835
837
|
if (proof.command.operation === "change") {
|
|
836
838
|
const [snapshot] = await tx<
|
|
@@ -1105,7 +1107,9 @@ export const createAppApprovalService = (
|
|
|
1105
1107
|
browserStatus: async (id: string, token: string, hold?: { ms: number; signal?: AbortSignal }) => {
|
|
1106
1108
|
const cfg = await config();
|
|
1107
1109
|
const read = async () => {
|
|
1108
|
-
const [row] = await db<
|
|
1110
|
+
const [row] = await db()<
|
|
1111
|
+
LoginRow[]
|
|
1112
|
+
>`SELECT * FROM auth.app_logins WHERE kind='browser' AND id=${id}::uuid AND issuer=${cfg.issuer}`;
|
|
1109
1113
|
if (!row || !matches(token, row.browser_hash)) return reject("UNAVAILABLE", 404);
|
|
1110
1114
|
const expiresIn = new Date(row.expires_at).getTime() - Date.now();
|
|
1111
1115
|
return { state: expiresIn <= 0 ? "expired" : row.state, expiresIn };
|
|
@@ -1134,7 +1138,7 @@ export const createAppApprovalService = (
|
|
|
1134
1138
|
},
|
|
1135
1139
|
consumeLogin: async (id: string, token: string) => {
|
|
1136
1140
|
const cfg = await config();
|
|
1137
|
-
return db.begin(async (tx) => {
|
|
1141
|
+
return db().begin(async (tx) => {
|
|
1138
1142
|
// Lock order is device -> request for both approval and completion.
|
|
1139
1143
|
const [snapshot] = await tx<
|
|
1140
1144
|
LoginRow[]
|