@k2b/cloud 0.15.0 → 0.17.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 +2 -2
- package/scripts/build.ts +11 -0
- package/src/_internal/cli-plugins.ts +248 -0
- package/src/_internal/define-app.ts +22 -1
- package/src/_internal/registry-validation.ts +7 -0
- package/src/access/PrincipalPicker.tsx +6 -2
- package/src/access/messages.ts +4 -0
- package/src/account/EntitySearch.tsx +8 -4
- package/src/account/avatar-upload-dialog.tsx +3 -2
- package/src/ai/chat/blocks.tsx +5 -2
- package/src/ai/chat/messages.ts +4 -0
- package/src/ai/chat/presentation.tsx +7 -2
- package/src/ai/pdf-render.ts +11 -2
- package/src/ai/routes.ts +5 -4
- package/src/api/admin-identity.ts +131 -3
- package/src/api/cli-plugins.ts +17 -0
- package/src/api/index.ts +1 -0
- package/src/cli/access.ts +11 -3
- package/src/cli/account.ts +6 -0
- package/src/cli/admin/agents.ts +219 -0
- package/src/cli/admin/index.ts +34 -0
- package/src/cli/index.ts +23 -0
- package/src/cli/plugin.ts +127 -0
- package/src/contracts/app.ts +13 -0
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +6 -4
- package/src/server/services/access.ts +16 -9
- package/src/services/accounts/entities.ts +7 -1
- package/src/services/index.ts +2 -1
- package/src/services/service-account-credentials.ts +73 -21
- package/src/services/service-accounts.ts +70 -1
- package/src/services/settings/core-settings.ts +13 -0
- package/src/ssr/Layout.tsx +3 -1
- package/src/styles/global.css +4 -0
- package/src/types/ambient.d.ts +5 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cld` plugin contract shared by the applications that serve plugins and by
|
|
3
|
+
* `cld`, which installs and loads them.
|
|
4
|
+
*
|
|
5
|
+
* An application serves each declared CLI module under
|
|
6
|
+
* `/cli/plugins/<name>/`: `manifest.json`, the bundled module `cli.js`, and
|
|
7
|
+
* its skill references `references/<path>.md`. The manifest lists every file
|
|
8
|
+
* with its SHA-512; the plugin digest is the SHA-512 of those lines, so one
|
|
9
|
+
* value identifies the exact module and references.
|
|
10
|
+
*/
|
|
11
|
+
import type { CloudCliModule } from "./index";
|
|
12
|
+
|
|
13
|
+
/** Version of the `CloudCliModule` shape a plugin's default export implements. */
|
|
14
|
+
export const CLOUD_CLI_API_VERSION = 1;
|
|
15
|
+
|
|
16
|
+
/** Gateway route under which applications serve their `cld` plugins. */
|
|
17
|
+
export const CLOUD_CLI_PLUGIN_ROUTE = "/cli/plugins";
|
|
18
|
+
|
|
19
|
+
/** Module names: `cld <name>`, the plugin ID, and the route segment. */
|
|
20
|
+
export const CLOUD_CLI_MODULE_NAME = /^[a-z][a-z0-9-]*$/;
|
|
21
|
+
|
|
22
|
+
/** File name of the bundled module inside a plugin. */
|
|
23
|
+
export const CLOUD_CLI_PLUGIN_ENTRY = "cli.js";
|
|
24
|
+
|
|
25
|
+
/** Entry file of a plugin's skill references. */
|
|
26
|
+
export const CLOUD_CLI_REFERENCE_INDEX = "references/index.md";
|
|
27
|
+
|
|
28
|
+
const PLUGIN_FILE_PATH = /^(?:cli\.js|references\/(?:[A-Za-z0-9_][A-Za-z0-9._-]*\/)*[A-Za-z0-9_][A-Za-z0-9._-]*\.md)$/;
|
|
29
|
+
const SHA512_HEX = /^[0-9a-f]{128}$/;
|
|
30
|
+
|
|
31
|
+
export type CloudCliPluginFile = { path: string; size: number; sha512: string };
|
|
32
|
+
|
|
33
|
+
export type CloudCliPluginManifest = {
|
|
34
|
+
apiVersion: number;
|
|
35
|
+
/** Module name and plugin ID. */
|
|
36
|
+
name: string;
|
|
37
|
+
/** ID of the application that serves the plugin. */
|
|
38
|
+
app: string;
|
|
39
|
+
/** Version of the serving application build. */
|
|
40
|
+
version: string;
|
|
41
|
+
entry: typeof CLOUD_CLI_PLUGIN_ENTRY;
|
|
42
|
+
/** SHA-512 over the file list; see {@link cloudCliPluginDigest}. */
|
|
43
|
+
digest: string;
|
|
44
|
+
files: CloudCliPluginFile[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** One entry of `GET /cli/plugins`. */
|
|
48
|
+
export type CloudCliPluginSummary = { name: string; app: string; version: string };
|
|
49
|
+
|
|
50
|
+
/** Whether `path` is a file name a plugin may contain. */
|
|
51
|
+
export const isCloudCliPluginFilePath = (path: string): boolean => PLUGIN_FILE_PATH.test(path);
|
|
52
|
+
|
|
53
|
+
export const sha512Hex = (bytes: Uint8Array | string): string => new Bun.CryptoHasher("sha512").update(bytes).digest("hex");
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Plugin digest: SHA-512 of `<sha512> <path>\n` for every file, sorted by
|
|
57
|
+
* path. This is the `sha512sum` line format, so a plugin directory can be
|
|
58
|
+
* checked with standard tools.
|
|
59
|
+
*/
|
|
60
|
+
export const cloudCliPluginDigest = (files: readonly Pick<CloudCliPluginFile, "path" | "sha512">[]): string =>
|
|
61
|
+
sha512Hex(
|
|
62
|
+
[...files]
|
|
63
|
+
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
|
|
64
|
+
.map((file) => `${file.sha512} ${file.path}\n`)
|
|
65
|
+
.join(""),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
69
|
+
|
|
70
|
+
/** Validate a served manifest, including its digest. Throws an `Error` naming the first problem. */
|
|
71
|
+
export const parseCloudCliPluginManifest = (value: unknown): CloudCliPluginManifest => {
|
|
72
|
+
if (!isRecord(value)) throw new Error("plugin manifest is not an object");
|
|
73
|
+
const { apiVersion, name, app, version, entry, digest, files } = value;
|
|
74
|
+
if (typeof apiVersion !== "number" || !Number.isInteger(apiVersion)) throw new Error("plugin manifest needs an integer apiVersion");
|
|
75
|
+
if (typeof name !== "string" || !CLOUD_CLI_MODULE_NAME.test(name)) throw new Error("plugin manifest needs a kebab-case name");
|
|
76
|
+
if (typeof app !== "string" || app === "") throw new Error("plugin manifest needs an app");
|
|
77
|
+
if (typeof version !== "string" || version === "") throw new Error("plugin manifest needs a version");
|
|
78
|
+
if (entry !== CLOUD_CLI_PLUGIN_ENTRY) throw new Error(`plugin manifest entry must be ${CLOUD_CLI_PLUGIN_ENTRY}`);
|
|
79
|
+
if (typeof digest !== "string" || !SHA512_HEX.test(digest)) throw new Error("plugin manifest needs a SHA-512 digest");
|
|
80
|
+
if (!Array.isArray(files)) throw new Error("plugin manifest needs a file list");
|
|
81
|
+
const seen = new Set<string>();
|
|
82
|
+
const parsed = files.map((file): CloudCliPluginFile => {
|
|
83
|
+
if (
|
|
84
|
+
!isRecord(file) ||
|
|
85
|
+
typeof file.path !== "string" ||
|
|
86
|
+
!isCloudCliPluginFilePath(file.path) ||
|
|
87
|
+
typeof file.size !== "number" ||
|
|
88
|
+
!Number.isSafeInteger(file.size) ||
|
|
89
|
+
file.size < 0 ||
|
|
90
|
+
typeof file.sha512 !== "string" ||
|
|
91
|
+
!SHA512_HEX.test(file.sha512)
|
|
92
|
+
) {
|
|
93
|
+
throw new Error("plugin manifest lists an invalid file");
|
|
94
|
+
}
|
|
95
|
+
if (seen.has(file.path)) throw new Error(`plugin manifest lists ${file.path} twice`);
|
|
96
|
+
seen.add(file.path);
|
|
97
|
+
return { path: file.path, size: file.size, sha512: file.sha512 };
|
|
98
|
+
});
|
|
99
|
+
if (!seen.has(CLOUD_CLI_PLUGIN_ENTRY)) throw new Error(`plugin manifest does not list ${CLOUD_CLI_PLUGIN_ENTRY}`);
|
|
100
|
+
if (!seen.has(CLOUD_CLI_REFERENCE_INDEX)) throw new Error(`plugin manifest does not list ${CLOUD_CLI_REFERENCE_INDEX}`);
|
|
101
|
+
if (cloudCliPluginDigest(parsed) !== digest) throw new Error("plugin manifest digest does not match its files");
|
|
102
|
+
return { apiVersion, name, app, version, entry, digest, files: parsed };
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check a plugin's default export structurally. `instanceof` would fail
|
|
107
|
+
* because every plugin bundles its own copy of `@k2b/cloud/cli`.
|
|
108
|
+
*/
|
|
109
|
+
export const validateCloudCliModule = (value: unknown): CloudCliModule => {
|
|
110
|
+
if (!isRecord(value)) throw new Error("default export is not a CLI module");
|
|
111
|
+
if (typeof value.name !== "string" || !CLOUD_CLI_MODULE_NAME.test(value.name)) {
|
|
112
|
+
throw new Error("default export needs a lowercase kebab-case name");
|
|
113
|
+
}
|
|
114
|
+
if (typeof value.summary !== "string") throw new Error("default export needs a string summary");
|
|
115
|
+
if (typeof value.run !== "function") throw new Error("default export needs a run function");
|
|
116
|
+
for (const key of ["help", "requiresCloudFor"] as const) {
|
|
117
|
+
if (value[key] !== undefined && typeof value[key] !== "function") throw new Error(`${key} must be a function`);
|
|
118
|
+
}
|
|
119
|
+
if (value.requiresCloud !== undefined && typeof value.requiresCloud !== "boolean") {
|
|
120
|
+
throw new Error("requiresCloud must be a boolean");
|
|
121
|
+
}
|
|
122
|
+
const flags = value.booleanFlags;
|
|
123
|
+
if (flags !== undefined && (!Array.isArray(flags) || flags.some((flag) => typeof flag !== "string"))) {
|
|
124
|
+
throw new Error("booleanFlags must be a string array");
|
|
125
|
+
}
|
|
126
|
+
return value as CloudCliModule;
|
|
127
|
+
};
|
package/src/contracts/app.ts
CHANGED
|
@@ -77,6 +77,17 @@ export type AppPresentationCatalog = {
|
|
|
77
77
|
translations: Readonly<Record<string, AppPresentationTranslation>>;
|
|
78
78
|
};
|
|
79
79
|
|
|
80
|
+
/** One `cld` module an application serves as a plugin. Paths are relative to the application directory. */
|
|
81
|
+
export type AppCliModule = {
|
|
82
|
+
/** Source file whose default export is the module from `defineCliCommands()`, e.g. `src/cli.ts`. */
|
|
83
|
+
module: string;
|
|
84
|
+
/** Directory of the module's skill references: `index.md` plus any further Markdown files. */
|
|
85
|
+
references: string;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** `cld` modules keyed by module name, the command `cld <name>`. */
|
|
89
|
+
export type AppCliModules = Readonly<Record<string, AppCliModule>>;
|
|
90
|
+
|
|
80
91
|
export type AppMeta = {
|
|
81
92
|
id: string;
|
|
82
93
|
name: string;
|
|
@@ -119,6 +130,8 @@ export type AppMeta = {
|
|
|
119
130
|
settingKeys?: readonly string[];
|
|
120
131
|
/** Gateway-relative URL where this app's OpenAPI JSON is served, or undefined. */
|
|
121
132
|
openapi?: string;
|
|
133
|
+
/** `cld` modules this app serves as plugins. */
|
|
134
|
+
cli?: AppCliModules;
|
|
122
135
|
};
|
|
123
136
|
|
|
124
137
|
export type WidgetEndpoint = {
|
|
@@ -85,4 +85,6 @@ export type AppRegistryEntry = {
|
|
|
85
85
|
settingKeys?: readonly string[];
|
|
86
86
|
/** Gateway-relative URL where this app serves its OpenAPI JSON spec. */
|
|
87
87
|
openapi?: string;
|
|
88
|
+
/** Names of the `cld` modules this app serves under `/cli/plugins/<name>`. */
|
|
89
|
+
cliModules?: readonly string[];
|
|
88
90
|
};
|
package/src/contracts/shared.ts
CHANGED
|
@@ -104,6 +104,9 @@ export const BaseGroupSchema = z.object({
|
|
|
104
104
|
});
|
|
105
105
|
export type BaseGroup = z.infer<typeof BaseGroupSchema>;
|
|
106
106
|
|
|
107
|
+
export const ServiceAccountKindSchema = z.enum(["user_delegated", "resource_bound", "standalone", "agent"]);
|
|
108
|
+
export type ServiceAccountKind = z.infer<typeof ServiceAccountKindSchema>;
|
|
109
|
+
|
|
107
110
|
export const EntityKindSchema = z.enum(["user", "group", "service_account"]);
|
|
108
111
|
export type EntityKind = z.infer<typeof EntityKindSchema>;
|
|
109
112
|
|
|
@@ -128,7 +131,7 @@ export const EntityListItemSchema = z.discriminatedUnion("kind", [
|
|
|
128
131
|
serviceAccount: z.object({
|
|
129
132
|
id: z.uuid(),
|
|
130
133
|
name: z.string(),
|
|
131
|
-
kind:
|
|
134
|
+
kind: ServiceAccountKindSchema,
|
|
132
135
|
status: z.enum(["active", "disabled"]),
|
|
133
136
|
delegatedUserId: z.uuid().nullable(),
|
|
134
137
|
appId: z.string().nullable(),
|
|
@@ -208,9 +211,6 @@ export type MutationResult<T = void> = { ok: true; data: T } | { ok: false; erro
|
|
|
208
211
|
export const PermissionLevelSchema = z.enum(["none", "read", "write", "admin"]);
|
|
209
212
|
export type PermissionLevel = z.infer<typeof PermissionLevelSchema>;
|
|
210
213
|
|
|
211
|
-
export const ServiceAccountKindSchema = z.enum(["user_delegated", "resource_bound"]);
|
|
212
|
-
export type ServiceAccountKind = z.infer<typeof ServiceAccountKindSchema>;
|
|
213
|
-
|
|
214
214
|
export const ServiceAccountStatusSchema = z.enum(["active", "disabled"]);
|
|
215
215
|
export type ServiceAccountStatus = z.infer<typeof ServiceAccountStatusSchema>;
|
|
216
216
|
|
|
@@ -327,6 +327,8 @@ export const AccessEntrySchema = z.object({
|
|
|
327
327
|
createdAt: z.string(),
|
|
328
328
|
displayName: z.string().optional(),
|
|
329
329
|
avatarHash: z.string().nullable().optional(),
|
|
330
|
+
/** Kind of a `service_account` principal; presentation only, never authorization. */
|
|
331
|
+
serviceAccountKind: ServiceAccountKindSchema.optional(),
|
|
330
332
|
});
|
|
331
333
|
export type AccessEntry = z.infer<typeof AccessEntrySchema>;
|
|
332
334
|
|
|
@@ -2,6 +2,7 @@ import { err, fail, ok, type Result } from "@k2b/stdlib";
|
|
|
2
2
|
import { type SQLQuery, sql } from "bun";
|
|
3
3
|
import { recursiveGroupIdsSubquery } from "../../services/accounts/group-sql";
|
|
4
4
|
import { toPgUuidArray } from "../../services/postgres";
|
|
5
|
+
import type { ServiceAccountKind } from "../../services/service-accounts";
|
|
5
6
|
|
|
6
7
|
export type AccessDb = typeof sql;
|
|
7
8
|
|
|
@@ -67,6 +68,7 @@ export type AccessEntry = {
|
|
|
67
68
|
// Resolved display info (populated by service)
|
|
68
69
|
displayName?: string;
|
|
69
70
|
avatarHash?: string | null;
|
|
71
|
+
serviceAccountKind?: ServiceAccountKind;
|
|
70
72
|
};
|
|
71
73
|
|
|
72
74
|
export type AccessUserSource =
|
|
@@ -566,11 +568,12 @@ export const listUsersWithAccess = async (params: {
|
|
|
566
568
|
|
|
567
569
|
/**
|
|
568
570
|
* Resolve display names for access entries.
|
|
569
|
-
* Populates the displayName field based on principal type
|
|
571
|
+
* Populates the displayName field based on principal type, and the
|
|
572
|
+
* serviceAccountKind field for service-account principals.
|
|
570
573
|
*/
|
|
571
574
|
export const resolveDisplayNames = async <T extends { principal: Principal }>(
|
|
572
575
|
entries: T[],
|
|
573
|
-
): Promise<(T & { displayName: string; avatarHash?: string | null })[]> => {
|
|
576
|
+
): Promise<(T & { displayName: string; avatarHash?: string | null; serviceAccountKind?: ServiceAccountKind })[]> => {
|
|
574
577
|
const userIds = entries.filter((e) => e.principal.type === "user").map((e) => (e.principal as { type: "user"; userId: string }).userId);
|
|
575
578
|
|
|
576
579
|
const groupIds = entries
|
|
@@ -606,21 +609,22 @@ export const resolveDisplayNames = async <T extends { principal: Principal }>(
|
|
|
606
609
|
}
|
|
607
610
|
}
|
|
608
611
|
|
|
609
|
-
const
|
|
612
|
+
const serviceAccountsById = new Map<string, { id: string; name: string; kind: ServiceAccountKind }>();
|
|
610
613
|
if (serviceAccountIds.length > 0) {
|
|
611
|
-
const serviceAccounts = await sql<{ id: string; name: string }[]>`
|
|
612
|
-
SELECT id, name
|
|
614
|
+
const serviceAccounts = await sql<{ id: string; name: string; kind: ServiceAccountKind }[]>`
|
|
615
|
+
SELECT id, name, kind
|
|
613
616
|
FROM auth.service_accounts
|
|
614
617
|
WHERE id = ANY(${toPgUuidArray(serviceAccountIds)}::uuid[])
|
|
615
618
|
`;
|
|
616
619
|
for (const serviceAccount of serviceAccounts) {
|
|
617
|
-
|
|
620
|
+
serviceAccountsById.set(serviceAccount.id, serviceAccount);
|
|
618
621
|
}
|
|
619
622
|
}
|
|
620
623
|
|
|
621
624
|
return entries.map((entry) => {
|
|
622
625
|
let displayName: string;
|
|
623
626
|
let avatarHash: string | null | undefined;
|
|
627
|
+
let serviceAccountKind: ServiceAccountKind | undefined;
|
|
624
628
|
switch (entry.principal.type) {
|
|
625
629
|
case "user": {
|
|
626
630
|
const user = userNames.get(entry.principal.userId);
|
|
@@ -631,9 +635,12 @@ export const resolveDisplayNames = async <T extends { principal: Principal }>(
|
|
|
631
635
|
case "group":
|
|
632
636
|
displayName = groupNames.get(entry.principal.groupId) ?? "Unknown Group";
|
|
633
637
|
break;
|
|
634
|
-
case "service_account":
|
|
635
|
-
|
|
638
|
+
case "service_account": {
|
|
639
|
+
const serviceAccount = serviceAccountsById.get(entry.principal.serviceAccountId);
|
|
640
|
+
displayName = serviceAccount?.name ?? "Unknown Service Account";
|
|
641
|
+
serviceAccountKind = serviceAccount?.kind;
|
|
636
642
|
break;
|
|
643
|
+
}
|
|
637
644
|
case "authenticated":
|
|
638
645
|
displayName = "All users (incl. guests)";
|
|
639
646
|
break;
|
|
@@ -641,6 +648,6 @@ export const resolveDisplayNames = async <T extends { principal: Principal }>(
|
|
|
641
648
|
displayName = "Public";
|
|
642
649
|
break;
|
|
643
650
|
}
|
|
644
|
-
return { ...entry, displayName, avatarHash };
|
|
651
|
+
return { ...entry, displayName, avatarHash, serviceAccountKind };
|
|
645
652
|
});
|
|
646
653
|
};
|
|
@@ -2,6 +2,7 @@ import { sql } from "bun";
|
|
|
2
2
|
import type { EntityKind, EntityListItem, UserProfile, UserProvider } from "../../contracts/shared";
|
|
3
3
|
import { getFreeIpaConfig } from "../freeipa-config";
|
|
4
4
|
import { escapeLikePattern, toPgTextArray, toPgUuidArray } from "../postgres";
|
|
5
|
+
import type { ServiceAccountKind } from "../service-accounts";
|
|
5
6
|
import { buildBaseGroup, personalOwnerJoin } from "./base-group";
|
|
6
7
|
import { buildBaseUser } from "./base-user";
|
|
7
8
|
import { buildManagedGroupScopeCondition, recursiveGroupIdsSubquery } from "./group-sql";
|
|
@@ -369,6 +370,9 @@ const buildQuerySpec = (params: EntityListParams): EntityQuerySpec => {
|
|
|
369
370
|
return buildNoRelationSpec();
|
|
370
371
|
};
|
|
371
372
|
|
|
373
|
+
const serviceAccountKind = (value: unknown): ServiceAccountKind =>
|
|
374
|
+
value === "resource_bound" || value === "standalone" || value === "agent" ? value : "user_delegated";
|
|
375
|
+
|
|
372
376
|
const mapEntityRow = (row: DbRow): EntityListItem => {
|
|
373
377
|
const direct = typeof row.direct === "boolean" ? row.direct : undefined;
|
|
374
378
|
|
|
@@ -386,7 +390,7 @@ const mapEntityRow = (row: DbRow): EntityListItem => {
|
|
|
386
390
|
serviceAccount: {
|
|
387
391
|
id: String(row.id),
|
|
388
392
|
name: String(row.name ?? ""),
|
|
389
|
-
kind: row.service_account_kind
|
|
393
|
+
kind: serviceAccountKind(row.service_account_kind),
|
|
390
394
|
status: row.status === "disabled" ? "disabled" : "active",
|
|
391
395
|
delegatedUserId: typeof row.delegated_user_id === "string" ? row.delegated_user_id : null,
|
|
392
396
|
appId: typeof row.app_id === "string" ? row.app_id : null,
|
|
@@ -596,6 +600,8 @@ export const list = async (
|
|
|
596
600
|
sa.name,
|
|
597
601
|
CASE
|
|
598
602
|
WHEN sa.kind = 'user_delegated' THEN 'Personal automation keys'
|
|
603
|
+
WHEN sa.kind = 'agent' THEN 'Agent'
|
|
604
|
+
WHEN sa.kind = 'standalone' THEN 'Service account'
|
|
599
605
|
ELSE CONCAT_WS(' · ', sa.app_id, sa.resource_type, sa.resource_id)
|
|
600
606
|
END AS description,
|
|
601
607
|
NULL::int AS gid_number,
|
package/src/services/index.ts
CHANGED
|
@@ -90,7 +90,8 @@ export type {
|
|
|
90
90
|
MandateSubject,
|
|
91
91
|
} from "./mandates";
|
|
92
92
|
export { serviceAccounts } from "./service-accounts";
|
|
93
|
-
export type { ServiceAccount, ServiceAccountKind, ServiceAccountStatus } from "./service-accounts";
|
|
93
|
+
export type { ServiceAccount, ServiceAccountKind, ServiceAccountStatus, StandaloneServiceAccountKind } from "./service-accounts";
|
|
94
|
+
export { isStandaloneServiceAccountKind, STANDALONE_SERVICE_ACCOUNT_KINDS } from "./service-accounts";
|
|
94
95
|
export { serviceAccountCredentials } from "./service-account-credentials";
|
|
95
96
|
export type {
|
|
96
97
|
AuthenticatedServiceAccountCredential,
|
|
@@ -5,7 +5,12 @@ import type { User } from "../contracts/shared";
|
|
|
5
5
|
import { accounts } from "./accounts";
|
|
6
6
|
import { audit } from "./audit";
|
|
7
7
|
import { isUniqueViolation, toPgTextArray } from "./postgres";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
isStandaloneServiceAccountKind,
|
|
10
|
+
type ServiceAccount,
|
|
11
|
+
type StandaloneServiceAccountKind,
|
|
12
|
+
serviceAccounts,
|
|
13
|
+
} from "./service-accounts";
|
|
9
14
|
|
|
10
15
|
export type ServiceAccountCredentialStatus = "active" | "revoked";
|
|
11
16
|
export type ServiceAccountCredentialKind = "api_token";
|
|
@@ -46,6 +51,12 @@ export type ServiceAccountCredentialOwner =
|
|
|
46
51
|
appId: string;
|
|
47
52
|
resourceType: string;
|
|
48
53
|
resourceId: string;
|
|
54
|
+
}
|
|
55
|
+
| {
|
|
56
|
+
type: "standalone";
|
|
57
|
+
serviceAccountId: string;
|
|
58
|
+
name: string;
|
|
59
|
+
kind: StandaloneServiceAccountKind;
|
|
49
60
|
};
|
|
50
61
|
|
|
51
62
|
export type ServiceAccountCredentialOverview = ServiceAccountCredential & {
|
|
@@ -139,30 +150,33 @@ const mapServiceAccount = (row: DbCredentialServiceAccountFields): ServiceAccoun
|
|
|
139
150
|
createdAt: row.service_account_created_at.toISOString(),
|
|
140
151
|
});
|
|
141
152
|
|
|
142
|
-
const
|
|
143
|
-
|
|
153
|
+
const credentialOwner = (row: DbCredentialOverviewRow, serviceAccount: ServiceAccount): ServiceAccountCredentialOwner => {
|
|
154
|
+
if (serviceAccount.kind === "user_delegated" && serviceAccount.delegatedUserId) {
|
|
155
|
+
return {
|
|
156
|
+
type: "user",
|
|
157
|
+
userId: serviceAccount.delegatedUserId,
|
|
158
|
+
uid: row.delegated_uid ?? serviceAccount.delegatedUserId,
|
|
159
|
+
displayName: row.delegated_display_name ?? "",
|
|
160
|
+
mail: row.delegated_mail,
|
|
161
|
+
avatarHash: row.delegated_avatar_hash,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (isStandaloneServiceAccountKind(serviceAccount.kind)) {
|
|
165
|
+
return { type: "standalone", serviceAccountId: serviceAccount.id, name: serviceAccount.name, kind: serviceAccount.kind };
|
|
166
|
+
}
|
|
144
167
|
return {
|
|
145
|
-
|
|
146
|
-
serviceAccount,
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
? {
|
|
150
|
-
type: "user",
|
|
151
|
-
userId: serviceAccount.delegatedUserId,
|
|
152
|
-
uid: row.delegated_uid ?? serviceAccount.delegatedUserId,
|
|
153
|
-
displayName: row.delegated_display_name ?? "",
|
|
154
|
-
mail: row.delegated_mail,
|
|
155
|
-
avatarHash: row.delegated_avatar_hash,
|
|
156
|
-
}
|
|
157
|
-
: {
|
|
158
|
-
type: "resource",
|
|
159
|
-
appId: serviceAccount.appId ?? "",
|
|
160
|
-
resourceType: serviceAccount.resourceType ?? "",
|
|
161
|
-
resourceId: serviceAccount.resourceId ?? "",
|
|
162
|
-
},
|
|
168
|
+
type: "resource",
|
|
169
|
+
appId: serviceAccount.appId ?? "",
|
|
170
|
+
resourceType: serviceAccount.resourceType ?? "",
|
|
171
|
+
resourceId: serviceAccount.resourceId ?? "",
|
|
163
172
|
};
|
|
164
173
|
};
|
|
165
174
|
|
|
175
|
+
const mapCredentialOverview = (row: DbCredentialOverviewRow): ServiceAccountCredentialOverview => {
|
|
176
|
+
const serviceAccount = mapServiceAccount(row);
|
|
177
|
+
return { ...mapCredential(row), serviceAccount, owner: credentialOwner(row, serviceAccount) };
|
|
178
|
+
};
|
|
179
|
+
|
|
166
180
|
const actorForUser = (user: Pick<User, "id" | "uid" | "provider" | "roles">) => ({
|
|
167
181
|
userId: user.id,
|
|
168
182
|
uid: user.uid,
|
|
@@ -438,6 +452,43 @@ export const createResourceApiToken = async (params: {
|
|
|
438
452
|
});
|
|
439
453
|
};
|
|
440
454
|
|
|
455
|
+
/** Static bearer for a standalone principal (kind standalone or agent); the raw token is returned once. */
|
|
456
|
+
export const createStandaloneApiToken = async (params: {
|
|
457
|
+
serviceAccountId: string;
|
|
458
|
+
actor: User;
|
|
459
|
+
name: string;
|
|
460
|
+
expiresAt?: string | null;
|
|
461
|
+
scopes?: string[];
|
|
462
|
+
}): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
|
|
463
|
+
const serviceAccount = await serviceAccounts.get({ id: params.serviceAccountId });
|
|
464
|
+
if (!serviceAccount || !isStandaloneServiceAccountKind(serviceAccount.kind)) return fail(err.notFound("Service account"));
|
|
465
|
+
if (serviceAccount.status !== "active") return fail(err.badInput("Service account is disabled"));
|
|
466
|
+
|
|
467
|
+
return sql.begin(async (tx) => {
|
|
468
|
+
const result = await insertApiToken(tx, {
|
|
469
|
+
serviceAccountId: serviceAccount.id,
|
|
470
|
+
name: params.name,
|
|
471
|
+
expiresAt: params.expiresAt,
|
|
472
|
+
createdBy: params.actor.id,
|
|
473
|
+
scopes: params.scopes,
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
return audit.recordResult({
|
|
477
|
+
action: "service_account_credential.create",
|
|
478
|
+
actor: actorForUser(params.actor),
|
|
479
|
+
target: { type: "service_account_credential", id: result.ok ? result.data.credential.id : null, label: params.name },
|
|
480
|
+
metadata: {
|
|
481
|
+
serviceAccountId: serviceAccount.id,
|
|
482
|
+
kind: "api_token",
|
|
483
|
+
serviceAccountKind: serviceAccount.kind,
|
|
484
|
+
expiresAt: params.expiresAt ?? null,
|
|
485
|
+
},
|
|
486
|
+
result,
|
|
487
|
+
db: tx,
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
};
|
|
491
|
+
|
|
441
492
|
export const listForDelegatedUser = async (params: { userId: string }): Promise<ServiceAccountCredential[]> => {
|
|
442
493
|
const rows = await sql<DbCredentialRow[]>`
|
|
443
494
|
SELECT c.id, c.service_account_id, c.name, c.kind, c.status, c.token_prefix, c.scopes, c.expires_at,
|
|
@@ -880,6 +931,7 @@ export const serviceAccountCredentials = {
|
|
|
880
931
|
createApiToken,
|
|
881
932
|
createUserApiToken,
|
|
882
933
|
createResourceApiToken,
|
|
934
|
+
createStandaloneApiToken,
|
|
883
935
|
listForDelegatedUser,
|
|
884
936
|
listOverview,
|
|
885
937
|
getOverview,
|
|
@@ -2,9 +2,20 @@ import { err, fail, ok, type Result } from "@k2b/stdlib";
|
|
|
2
2
|
import { sql } from "bun";
|
|
3
3
|
import { isUniqueViolation } from "./postgres";
|
|
4
4
|
|
|
5
|
-
export type ServiceAccountKind = "user_delegated" | "resource_bound";
|
|
5
|
+
export type ServiceAccountKind = "user_delegated" | "resource_bound" | "standalone" | "agent";
|
|
6
6
|
export type ServiceAccountStatus = "active" | "disabled";
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Kinds of a standalone principal: not delegated by a user and not bound to one
|
|
10
|
+
* app resource. `agent` is a standalone account that surfaces as an agent
|
|
11
|
+
* (badge, pickers, activity); it carries no extra permission.
|
|
12
|
+
*/
|
|
13
|
+
export type StandaloneServiceAccountKind = Extract<ServiceAccountKind, "standalone" | "agent">;
|
|
14
|
+
export const STANDALONE_SERVICE_ACCOUNT_KINDS = ["standalone", "agent"] as const satisfies readonly StandaloneServiceAccountKind[];
|
|
15
|
+
|
|
16
|
+
export const isStandaloneServiceAccountKind = (kind: ServiceAccountKind): kind is StandaloneServiceAccountKind =>
|
|
17
|
+
kind === "standalone" || kind === "agent";
|
|
18
|
+
|
|
8
19
|
export type ServiceAccount = {
|
|
9
20
|
id: string;
|
|
10
21
|
name: string;
|
|
@@ -48,6 +59,8 @@ const trimRequired = (value: string): string => value.trim();
|
|
|
48
59
|
|
|
49
60
|
const isForeignKeyViolation = (error: unknown): boolean => (error as { code?: string } | null)?.code === "23503";
|
|
50
61
|
const RESOURCE_BOUND_UNIQUE_CONSTRAINT = "uniq_service_accounts_resource_bound";
|
|
62
|
+
const STANDALONE_NAME_UNIQUE_CONSTRAINT = "uniq_service_accounts_standalone_name";
|
|
63
|
+
const MAX_NAME_LENGTH = 120;
|
|
51
64
|
|
|
52
65
|
export const getByResource = async (params: {
|
|
53
66
|
appId: string;
|
|
@@ -125,6 +138,60 @@ export const createResourceBound = async (params: {
|
|
|
125
138
|
}
|
|
126
139
|
};
|
|
127
140
|
|
|
141
|
+
export const createStandalone = async (params: {
|
|
142
|
+
name: string;
|
|
143
|
+
kind: StandaloneServiceAccountKind;
|
|
144
|
+
createdBy?: string | null;
|
|
145
|
+
}): Promise<Result<ServiceAccount>> => {
|
|
146
|
+
const name = trimRequired(params.name);
|
|
147
|
+
if (!name) return fail(err.badInput("Service account name is required"));
|
|
148
|
+
if (name.length > MAX_NAME_LENGTH) return fail(err.badInput(`Service account name must be ${MAX_NAME_LENGTH} characters or fewer`));
|
|
149
|
+
if (!isStandaloneServiceAccountKind(params.kind)) return fail(err.badInput("Service account kind must be standalone or agent"));
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const [row] = await sql<DbServiceAccount[]>`
|
|
153
|
+
INSERT INTO auth.service_accounts (name, kind, created_by)
|
|
154
|
+
VALUES (${name}, ${params.kind}, ${params.createdBy ?? null}::uuid)
|
|
155
|
+
RETURNING id, name, kind, status, delegated_user_id, app_id, resource_type, resource_id, created_by, created_at
|
|
156
|
+
`;
|
|
157
|
+
return row ? ok(mapServiceAccount(row)) : fail(err.internal("Failed to create service account"));
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (isForeignKeyViolation(error)) return fail(err.notFound("Creator"));
|
|
160
|
+
if (isUniqueViolation(error, STANDALONE_NAME_UNIQUE_CONSTRAINT)) return fail(err.conflict("Service account name"));
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** Bounded page of standalone principals; user-delegated and resource-bound accounts stay with their owners. */
|
|
166
|
+
export const listStandalone = async (params: {
|
|
167
|
+
kind?: StandaloneServiceAccountKind;
|
|
168
|
+
status?: ServiceAccountStatus;
|
|
169
|
+
search?: string;
|
|
170
|
+
page?: number;
|
|
171
|
+
perPage?: number;
|
|
172
|
+
}): Promise<{ items: ServiceAccount[]; page: number; perPage: number; total: number; hasNext: boolean }> => {
|
|
173
|
+
const page = Math.max(1, params.page ?? 1);
|
|
174
|
+
const perPage = Math.max(1, Math.min(params.perPage ?? 100, 500));
|
|
175
|
+
const offset = (page - 1) * perPage;
|
|
176
|
+
const kind = params.kind ?? null;
|
|
177
|
+
const status = params.status ?? null;
|
|
178
|
+
const search = params.search?.trim() || null;
|
|
179
|
+
const rows = await sql<(DbServiceAccount & { total: number })[]>`
|
|
180
|
+
SELECT id, name, kind, status, delegated_user_id, app_id, resource_type, resource_id, created_by, created_at,
|
|
181
|
+
COUNT(*) OVER()::int AS total
|
|
182
|
+
FROM auth.service_accounts
|
|
183
|
+
WHERE kind IN ('standalone', 'agent')
|
|
184
|
+
AND (${kind}::text IS NULL OR kind = ${kind})
|
|
185
|
+
AND (${status}::text IS NULL OR status = ${status})
|
|
186
|
+
AND (${search}::text IS NULL OR name ILIKE '%' || ${search} || '%')
|
|
187
|
+
ORDER BY created_at DESC, id DESC
|
|
188
|
+
LIMIT ${perPage}
|
|
189
|
+
OFFSET ${offset}
|
|
190
|
+
`;
|
|
191
|
+
const total = rows[0]?.total ?? 0;
|
|
192
|
+
return { items: rows.map(mapServiceAccount), page, perPage, total, hasNext: page * perPage < total };
|
|
193
|
+
};
|
|
194
|
+
|
|
128
195
|
export const getOrCreateResourceBound = async (params: {
|
|
129
196
|
name: string;
|
|
130
197
|
appId: string;
|
|
@@ -176,6 +243,8 @@ export const serviceAccounts = {
|
|
|
176
243
|
get,
|
|
177
244
|
createUserDelegated,
|
|
178
245
|
createResourceBound,
|
|
246
|
+
createStandalone,
|
|
247
|
+
listStandalone,
|
|
179
248
|
getByResource,
|
|
180
249
|
getOrCreateResourceBound,
|
|
181
250
|
setStatus,
|
|
@@ -709,6 +709,19 @@ export const CORE_SETTINGS = {
|
|
|
709
709
|
description: "Maximum API requests per second per IP address",
|
|
710
710
|
},
|
|
711
711
|
|
|
712
|
+
// ── Command line ────────────────────────────────────────────────────────
|
|
713
|
+
"cli.plugins.access": {
|
|
714
|
+
kind: "enum",
|
|
715
|
+
label: "Who may install cld plugins",
|
|
716
|
+
default: "full_users",
|
|
717
|
+
options: [
|
|
718
|
+
{ value: "full_users", label: "Full accounts" },
|
|
719
|
+
{ value: "all_users", label: "All accounts, including guests" },
|
|
720
|
+
],
|
|
721
|
+
description:
|
|
722
|
+
"Applies to the plugin list and every plugin download. Signed-out requests never get plugins. Commands stay authorized by the server either way.",
|
|
723
|
+
},
|
|
724
|
+
|
|
712
725
|
// ── Legal documents (Imprint, Privacy, Terms) ──────────────────────────
|
|
713
726
|
// mode = "local" → render markdown from `legal.<kind>.content`
|
|
714
727
|
// mode = "external" → 302-redirect to `legal.<kind>.url`
|
package/src/ssr/Layout.tsx
CHANGED
|
@@ -210,7 +210,9 @@ export default function Layout(props: LayoutProps) {
|
|
|
210
210
|
user?.avatarHash && user.id
|
|
211
211
|
? `/api/accounts/users/${encodeURIComponent(user.id)}/avatar?rev=${encodeURIComponent(user.avatarHash)}`
|
|
212
212
|
: undefined;
|
|
213
|
-
|
|
213
|
+
// From lg, `main` is the page scroller of regular pages. Its stable gutter
|
|
214
|
+
// keeps short and long pages at the same width where scrollbars take space.
|
|
215
|
+
const mainLayoutClass = fullPage || fullWidth ? "flex flex-col" : "lg:overflow-auto lg:[scrollbar-gutter:stable]";
|
|
214
216
|
const canvasStyle =
|
|
215
217
|
[appAppearanceStyle(currentApp?.appearance), appWorkspaceLayoutStyle(workspaceLayout), focusMode && flushCanvas ? "padding:0" : ""]
|
|
216
218
|
.filter(Boolean)
|
package/src/styles/global.css
CHANGED
|
@@ -280,6 +280,10 @@ solid-island {
|
|
|
280
280
|
padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);
|
|
281
281
|
background: var(--ui-surface);
|
|
282
282
|
}
|
|
283
|
+
/* Below lg the document is the page scroller; reserve its scrollbar gutter
|
|
284
|
+
so short and long pages keep the same width. Full-page surfaces own their
|
|
285
|
+
inner scrolling. */
|
|
286
|
+
html:has(.cloud-app-canvas:not([data-layout-full-page])) { scrollbar-gutter: stable; }
|
|
283
287
|
.cloud-app-canvas > .layout-shell-content { gap: 0; }
|
|
284
288
|
.cloud-app-canvas .layout-header {
|
|
285
289
|
border-radius: 0; border: 0; border-bottom: 1px solid var(--ui-border);
|
package/src/types/ambient.d.ts
CHANGED