@nodaro/shared 2.7.0 → 2.10.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/src/llm-models.ts CHANGED
@@ -15,6 +15,7 @@
15
15
 
16
16
  export type LlmTier = "economy" | "standard" | "premium"
17
17
  export type KieApiFormat = "chat-completions" | "messages" | "responses"
18
+ export type LlmVendor = "anthropic" | "google" | "openai" | "xai"
18
19
 
19
20
  export const LLM_REASONING_EFFORTS = ["none", "low", "medium", "high", "xhigh", "max"] as const
20
21
  export type LlmReasoningEffort = (typeof LLM_REASONING_EFFORTS)[number]
@@ -32,7 +33,7 @@ export interface LlmModelDef {
32
33
  * For messages: the model id sent in the body (e.g. "claude-haiku-4-5-v1messages").
33
34
  * For responses: the model id sent in the body (e.g. "gpt-5-4"). */
34
35
  kieSlugOrModel: string
35
- vendor: "anthropic" | "google" | "openai"
36
+ vendor: LlmVendor
36
37
  supportsImages: boolean
37
38
  maxOutputTokens: number
38
39
  /**
@@ -159,6 +160,30 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
159
160
  // direct is the reliability fallback only.
160
161
  directGeminiModel: "gemini-3.6-flash",
161
162
  },
163
+ {
164
+ id: "gemini-3.7-flash",
165
+ displayName: "Gemini 3.7 Flash",
166
+ desc: "Newest fast Gemini, agentic-tuned",
167
+ tier: "economy",
168
+ kieFormat: "chat-completions",
169
+ // KIE serves it on the OpenAI-compatible dialect under this slug
170
+ // (docs.kie.ai/market/gemini/gemini-3-7-flash-openai.md) — same
171
+ // chat-completions path shape as gemini-3.6-flash.
172
+ kieSlugOrModel: "gemini-3-7-flash-openai",
173
+ vendor: "google",
174
+ structuredOutputMode: "kie-response-format",
175
+ supportsImages: true,
176
+ // Google's own cap is 65,536, but the field feeds BOTH lanes and the KIE
177
+ // flash endpoints cap at 8192 (the measured 3.6 posture) — stay at the
178
+ // KIE-safe intersection, same reasoning as `reasoningEfforts` below.
179
+ maxOutputTokens: 8192,
180
+ // KIE's 3.7 endpoint enumerates reasoning_effort low | high (verified
181
+ // against its OpenAPI spec 2026-08-18), identical to 3.6.
182
+ reasoningEfforts: ["low", "high"],
183
+ // Assumed parity with 3.6 pending a live probe on the direct lane.
184
+ directReasoningEfforts: ["none", "low", "medium", "high"],
185
+ directGeminiModel: "gemini-3.7-flash",
186
+ },
162
187
  {
163
188
  id: "claude-haiku-4.5",
164
189
  displayName: "Claude Haiku 4.5",
@@ -321,7 +346,35 @@ export const LLM_MODELS: readonly LlmModelDef[] = [
321
346
  reasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"],
322
347
  supportsTemperature: false,
323
348
  },
324
- // grok-4.5 deferred — KIE chat endpoint not yet live (2026-07-13); add entry + rate row + docs when it activates.
349
+ {
350
+ id: "grok-4.6",
351
+ displayName: "Grok 4.6",
352
+ desc: "xAI flagship, strong reasoning",
353
+ tier: "standard",
354
+ kieFormat: "responses",
355
+ // KIE serves Grok on the responses dialect under its own family path —
356
+ // grok/v1/responses, NOT codex/v1/responses (llm-client derives the path
357
+ // from `vendor`). Live-verified end-to-end 2026-08-18: array `input`,
358
+ // `developer` system role, `input_image` URL vision, `text.format`
359
+ // json_schema enforcement, SSE `response.output_text.delta` stream, and
360
+ // `credits_consumed` actual-cost capture. (grok-4.5 was deferred 2026-07-13
361
+ // because none of this was live; 4.6 is its activation.)
362
+ kieSlugOrModel: "grok-4-6",
363
+ vendor: "xai",
364
+ structuredOutputMode: "responses-json-schema",
365
+ supportsImages: true,
366
+ maxOutputTokens: 16384,
367
+ // KIE's documented enum, each level live-verified (echoed back) 2026-08-18.
368
+ // No `none`: the endpoint reasons unconditionally (see thinkingDefaultOn).
369
+ reasoningEfforts: ["low", "medium", "high", "xhigh"],
370
+ // Live-probed 2026-08-18: `temperature` is silently IGNORED (request echo
371
+ // stays at the 0.7 default), so never send it — same treatment as GPT-5.5+.
372
+ supportsTemperature: false,
373
+ // Reasons with NO reasoning param sent (effort defaults to "low" server-side
374
+ // — a trivial probe spent 169 of 170 output tokens on reasoning), so every
375
+ // call needs output headroom, not just xhigh.
376
+ thinkingDefaultOn: true,
377
+ },
325
378
  {
326
379
  id: "claude-sonnet-5",
327
380
  displayName: "Claude Sonnet 5",
@@ -407,6 +460,55 @@ export const STRUCTURED_VISION_MODELS = LLM_MODELS.filter(
407
460
  (m) => m.supportsImages && m.structuredOutputMode != null,
408
461
  )
409
462
 
463
+ /**
464
+ * Vendor presentation order + labels for model pickers. Every LlmVendor MUST
465
+ * appear in the order list (guarded by a registry test) so a new vendor can't
466
+ * ship with its models silently sorted to the end of every menu unlabeled.
467
+ * Alphabetical on purpose: stable, and no vendor-preference fights.
468
+ */
469
+ export const LLM_VENDOR_ORDER: readonly LlmVendor[] = ["anthropic", "google", "openai", "xai"]
470
+ export const LLM_VENDOR_LABELS: Record<LlmVendor, string> = {
471
+ anthropic: "Anthropic",
472
+ google: "Google",
473
+ openai: "OpenAI",
474
+ xai: "xAI",
475
+ }
476
+
477
+ const TIER_RANK: Record<LlmTier, number> = { economy: 0, standard: 1, premium: 2 }
478
+
479
+ export interface LlmModelGroup {
480
+ vendor: LlmVendor
481
+ /** Display heading for the group (LLM_VENDOR_LABELS[vendor]). */
482
+ label: string
483
+ models: LlmModelDef[]
484
+ }
485
+
486
+ /**
487
+ * The ONE ordering every LLM model menu renders: grouped by vendor (in
488
+ * LLM_VENDOR_ORDER), and inside each group sorted economy → standard → premium
489
+ * (registry order breaks ties, which keeps family generations adjacent).
490
+ * A flat registry-order dump was genuinely hard to scan at 17 models — every
491
+ * picker (config panel, quick strips, quick toolbar) derives from this so the
492
+ * menus can't drift apart. Groups with no models (after `filter`) are omitted.
493
+ */
494
+ export function groupLlmModelsByVendor(models: readonly LlmModelDef[] = LLM_MODELS): LlmModelGroup[] {
495
+ const groups: LlmModelGroup[] = []
496
+ for (const vendor of LLM_VENDOR_ORDER) {
497
+ const members = models
498
+ .filter((m) => m.vendor === vendor)
499
+ .sort((a, b) => TIER_RANK[a.tier] - TIER_RANK[b.tier])
500
+ if (members.length > 0) groups.push({ vendor, label: LLM_VENDOR_LABELS[vendor], models: members })
501
+ }
502
+ return groups
503
+ }
504
+
505
+ /** {@link groupLlmModelsByVendor} flattened — for menus that can't render
506
+ * group headers (e.g. the compact node quick strips) but should still read
507
+ * vendor-clustered and tier-ordered. */
508
+ export function orderedLlmModels(models: readonly LlmModelDef[] = LLM_MODELS): LlmModelDef[] {
509
+ return groupLlmModelsByVendor(models).flatMap((g) => g.models)
510
+ }
511
+
410
512
  export type LlmFeature =
411
513
  | "ai-writer"
412
514
  | "llm-chat"
@@ -427,6 +529,9 @@ export type LlmFeature =
427
529
  // feature (not ai-writer, which it used to piggyback on) so the model
428
530
  // default and the tiered credit ids are the strategy's own.
429
531
  | "pick-best-llm"
532
+ // In-app Workflow Copilot turns (backend agent loop; metered, reservation
533
+ // ceiling under `STATIC_CREDIT_COSTS["workflow-copilot"]`).
534
+ | "workflow-copilot"
430
535
 
431
536
  /** Engine-dependent LlmFeature for the motion-graphics node (design §8: every credit-id site must branch on engine). */
432
537
  export function motionGraphicsFeature(engine?: string): LlmFeature {
@@ -451,6 +556,7 @@ export const LLM_FEATURE_DEFAULTS: Record<LlmFeature, string> = {
451
556
  "translate": "gemini-3.6-flash",
452
557
  "image-critic": "claude-sonnet-4.6",
453
558
  "pick-best-llm": "claude-sonnet-4.6",
559
+ "workflow-copilot": "claude-sonnet-5",
454
560
  }
455
561
 
456
562
  /**
@@ -465,6 +571,12 @@ export const LLM_FEATURE_DEFAULTS: Record<LlmFeature, string> = {
465
571
  export const LLM_MODALITY_CAPS: Record<string, { image: boolean; video: boolean; audio: boolean }> = {
466
572
  "gemini-3-flash": { image: true, video: true, audio: true },
467
573
  "gemini-3.6-flash": { image: true, video: true, audio: true },
574
+ // gemini-3.7-flash is IMAGE-ONLY by DECISION, not omission: full video+audio
575
+ // caps would auto-enroll it in VIDEO_ANALYSIS_LLM_MODELS (derived below) and
576
+ // force a video-analysis tier + pricing decision that is deliberately
577
+ // deferred while the smart-family A/B routes this model internally (#747).
578
+ // Flip these two flags ONLY together with that VA-side decision.
579
+ "gemini-3.7-flash": { image: true, video: false, audio: false },
468
580
  "gemini-3.1-pro": { image: true, video: true, audio: true },
469
581
  "claude-haiku-4.5": { image: true, video: false, audio: false },
470
582
  "claude-sonnet-4.6": { image: true, video: false, audio: false },
@@ -475,6 +587,7 @@ export const LLM_MODALITY_CAPS: Record<string, { image: boolean; video: boolean;
475
587
  "gpt-5.6-luna": { image: true, video: false, audio: false },
476
588
  "gpt-5.6-terra": { image: true, video: false, audio: false },
477
589
  "gpt-5.6-sol": { image: true, video: false, audio: false },
590
+ "grok-4.6": { image: true, video: false, audio: false },
478
591
  "claude-sonnet-5": { image: true, video: false, audio: false },
479
592
  "claude-opus-4.8": { image: true, video: false, audio: false },
480
593
  "claude-opus-5": { image: true, video: false, audio: false },
@@ -886,6 +886,9 @@ const IMAGE_MODELS: Record<string, ModelCatalogEntry> = {
886
886
  description:
887
887
  "Grok Imagine Image 2.0 — expressive, high-contrast t2i. Generations chain into grok-2-segment (free named region masks) and grok-2-edit (region-targeted edits).",
888
888
  useCases: ["stylized", "expressive", "general"],
889
+ // Refs auto-route to grok-2-i2i (segment-map → image-edit chain); ONE
890
+ // reference (REF_IMAGE_MAX_LIMITS).
891
+ features: ["reference-image"],
889
892
  aspectRatios: GROK_RATIOS,
890
893
  pricing: [{ identifier: "grok-2", credits: 10 }],
891
894
  },
@@ -901,6 +904,22 @@ const IMAGE_MODELS: Record<string, ModelCatalogEntry> = {
901
904
  useCases: ["edit", "region-edit"],
902
905
  pricing: [{ identifier: "grok-2-edit", credits: 10 }],
903
906
  },
907
+ // Auto-selected when references are attached to grok-2 (T2I_TO_I2I_VARIANT):
908
+ // the t2i endpoint takes no image, so the FREE segment-map mints a task id
909
+ // from the reference URL and image-edit consumes it. Single reference.
910
+ "grok-2-i2i": {
911
+ id: "grok-2-i2i",
912
+ kind: "image",
913
+ modes: ["i2i"] as const,
914
+ family: "xAI",
915
+ label: "Grok Imagine 2 (reference)",
916
+ series: "Grok",
917
+ description:
918
+ "grok-2 guided by ONE reference image via the segment-map → image-edit chain — preserves the reference's composition while applying the prompt.",
919
+ useCases: ["restyle", "edit", "reference"],
920
+ features: ["reference-image"],
921
+ pricing: [{ identifier: "grok-2-i2i", credits: 10 }],
922
+ },
904
923
  "grok-2-segment": {
905
924
  id: "grok-2-segment",
906
925
  kind: "image",
@@ -5,7 +5,7 @@
5
5
  import { z } from "zod"
6
6
  import { MODEL_CATALOG } from "./model-catalog.js"
7
7
 
8
- /** Base USD cost per 1 Nodaro credit, at cost. Used for cost→credit conversion. */
8
+ /** Base USD value of 1 Nodaro credit. Used for cost→credit conversion. */
9
9
  export const CREDIT_BASE_USD = 0.002
10
10
 
11
11
  /** Max characters for the (assembled) prompt accepted by the image-generation routes
@@ -232,16 +232,25 @@ export function getMaxTtsChars(provider: string | undefined): number {
232
232
  * Suno per-version field caps (from docs.kie.ai/suno-api/generate-music). The old
233
233
  * flat {@link SUNO_TEXT_MAX} (3000) was simultaneously too low for V4.5+/V5
234
234
  * prompts (5000) and too high for `style` (1000) and `title` (80).
235
- * - prompt / lyrics: 500 in non-custom mode (all versions); in custom mode
235
+ * - prompt / lyrics: 3000 in non-custom mode (all versions); in custom mode
236
236
  * 3000 for V4/V3.5 and 5000 for V4.5 / V4.5PLUS / V4.5ALL / V5 / V5.5.
237
237
  * - style: 200 for V4/V3.5, 1000 for V4.5+.
238
238
  * - title: 80 (all versions).
239
239
  */
240
240
  export const SUNO_TITLE_MAX = 80
241
241
 
242
- /** Max Suno `prompt` (= lyrics in custom mode) length for a model version. */
242
+ /**
243
+ * Max Suno `prompt` (= lyrics in custom mode) length for a model version.
244
+ *
245
+ * NON-CUSTOM WAS 500 UNTIL 2026-08-19 — six times under the provider's
246
+ * documented 3000, and the route TRUNCATES to this number instead of
247
+ * rejecting, so everything past it vanished without a trace. Field evidence:
248
+ * a 950-character recast score brief (instruments, vocal, the source's own
249
+ * scat syllables, the arrangement's arc) reached Suno as exactly 500
250
+ * characters, cut mid-word.
251
+ */
243
252
  export function getMaxSunoPromptChars(model: string | undefined, customMode: boolean): number {
244
- if (!customMode) return 500
253
+ if (!customMode) return 3000
245
254
  return model === "V4" || model === "V3_5" ? 3000 : 5000
246
255
  }
247
256
 
@@ -364,6 +373,7 @@ export const MODELS_WITH_REFERENCE_IMAGE_SUPPORT = new Set([
364
373
  "gpt-image",
365
374
  "gpt-image-2",
366
375
  "grok",
376
+ "grok-2",
367
377
  "qwen",
368
378
  "seedream",
369
379
  "seedream-5-lite",
@@ -374,6 +384,7 @@ export const MODELS_WITH_REFERENCE_IMAGE_SUPPORT = new Set([
374
384
  "nano-banana-edit",
375
385
  "gpt-image-i2i",
376
386
  "gpt-image-2-i2i",
387
+ "grok-2-i2i",
377
388
  "flux-i2i",
378
389
  "flux-pro-i2i",
379
390
  "flux-kontext",
@@ -412,6 +423,9 @@ export const T2I_TO_I2I_VARIANT: Record<string, string> = {
412
423
  "gpt-image": "gpt-image-i2i",
413
424
  "gpt-image-2": "gpt-image-2-i2i",
414
425
  "grok": "grok-i2i",
426
+ // grok-2's t2i takes NO image input; its "i2i" is the segment-map(image_url)
427
+ // → image-edit(task_id) chain in the KIE provider (single reference).
428
+ "grok-2": "grok-2-i2i",
415
429
  "qwen": "qwen-i2i",
416
430
  "seedream": "seedream-edit",
417
431
  "seedream-5-lite": "seedream-5-lite-i2i",
@@ -437,6 +451,8 @@ export const REF_IMAGE_MAX_LIMITS: Record<string, number> = {
437
451
  "nano-banana-2": 4,
438
452
  "nano-banana-2-lite": 10,
439
453
  "wan-2.7": 9,
454
+ // grok-2 reference chain consumes exactly one image (segment-map input).
455
+ "grok-2-i2i": 1,
440
456
  // Image-to-image (multi-source array)
441
457
  "nano-banana-edit": 8,
442
458
  "gpt-image-i2i": 16,
@@ -592,6 +608,7 @@ export const IMAGE_I2I_PROVIDERS = [
592
608
  "flux-pro-i2i",
593
609
  "gpt-image-i2i",
594
610
  "gpt-image-2-i2i",
611
+ "grok-2-i2i",
595
612
  "ideogram-edit",
596
613
  "ideogram-remix",
597
614
  "ideogram-reframe",
@@ -824,6 +841,35 @@ export function resolveVideoProviderForMode(
824
841
  return provider
825
842
  }
826
843
 
844
+ /**
845
+ * Which execution mode a unified Generate Video run takes from what is wired.
846
+ * Shared by the frontend DAG executor (`execute-node.ts`) and the backend
847
+ * orchestrator (`payload-builder.ts`) so the two cannot disagree.
848
+ *
849
+ * A start frame is image-to-video, full stop. Reference images ALONE are the
850
+ * nuance: most models forward refs on either path, but a split-id model
851
+ * (VIDEO_MODE_ALIASES) can carry them on one twin only — Grok Imagine 1's
852
+ * text-to-video endpoint has no image parameter at all, while its i2v twin
853
+ * takes up to 7. Refs wired without a start frame used to resolve to t2v →
854
+ * `grok` → silently dropped (#861). When refs are present and ONLY the i2v
855
+ * twin can carry them, the run is image-to-video with the refs as its images.
856
+ * Derived from VIDEO_REF_LIMITS_BY_PROVIDER (= the catalog's `reference-image`
857
+ * feature), never from a provider name; single-id models are untouched
858
+ * because both twins are the same id.
859
+ */
860
+ export function resolveVideoModeForInputs(
861
+ provider: string | undefined,
862
+ inputs: { readonly hasStartFrame: boolean; readonly hasImageRefs: boolean },
863
+ ): "image-to-video" | "text-to-video" {
864
+ if (inputs.hasStartFrame) return "image-to-video"
865
+ if (!provider || !inputs.hasImageRefs) return "text-to-video"
866
+ const i2v = resolveVideoProviderForMode(provider, "image-to-video")
867
+ const t2v = resolveVideoProviderForMode(provider, "text-to-video")
868
+ if (i2v === t2v) return "text-to-video"
869
+ const carriesImageRefs = (id: string) => (VIDEO_REF_LIMITS_BY_PROVIDER[id]?.images ?? 0) > 0
870
+ return carriesImageRefs(i2v) && !carriesImageRefs(t2v) ? "image-to-video" : "text-to-video"
871
+ }
872
+
827
873
  /**
828
874
  * t2v twin ids hidden from the unified Generate Video picker — the i2v/base
829
875
  * entry already represents both modes (execution remaps by image presence).
@@ -1526,9 +1572,8 @@ export const SEEDANCE_2_R2V_MIN_REF_VIDEO_SEC = 1.8
1526
1572
  * credit formulas bill per continuation join. Clears the provider floor
1527
1573
  * above with margin while staying short enough to keep the model focused on
1528
1574
  * continuing the boundary motion instead of re-staging the whole clip.
1529
- * Guarded ≥ floor by model-constants tests; the private-plugin twin
1530
- * (nodaro-cloud-plugins chain.ts TAIL_SEC / bridge-math.ts MIN_REF) is
1531
- * guarded by that repo's r2v-ref-floor.test.ts — keep the two in sync.
1575
+ * Guarded ≥ floor by model-constants tests; the plugin repo carries a twin
1576
+ * constant guarded by its own tests — keep the two in sync.
1532
1577
  */
1533
1578
  export const SEEDANCE_2_CONTINUATION_REF_SEC = 2
1534
1579
 
@@ -0,0 +1,2 @@
1
+ export * from "./types.js"
2
+ export * from "./views.js"
@@ -0,0 +1,152 @@
1
+ import { z } from "zod"
2
+
3
+ /**
4
+ * Organizations — wire contract for the second tenancy axis
5
+ * (Organization -> Workspace -> Member).
6
+ *
7
+ * Lives in @nodaro/shared because SDK/MCP consumers need these enums, the
8
+ * request schemas the API validates, and the error codes it returns. This
9
+ * package carries the CONTRACT ONLY — no resolution logic, no presets, no
10
+ * vocabulary, no access rule. Those are server-side.
11
+ */
12
+
13
+ /**
14
+ * Selects which workspace a request LISTS from and CREATES into. It never
15
+ * authorizes: reading, updating, deleting or running an identified object is
16
+ * decided by that object's own workspace, so a forgotten or forged header can
17
+ * neither widen access nor move a charge.
18
+ *
19
+ * Fastify lower-cases incoming header keys, hence the second constant — read
20
+ * `req.headers[WORKSPACE_HEADER_LOWER]`, send `WORKSPACE_HEADER`.
21
+ */
22
+ export const WORKSPACE_HEADER = "X-Nodaro-Workspace"
23
+ export const WORKSPACE_HEADER_LOWER = "x-nodaro-workspace"
24
+
25
+ export const ORG_KINDS = ["school", "team"] as const
26
+ export type OrgKind = (typeof ORG_KINDS)[number]
27
+
28
+ export const ORG_ROLES = ["owner", "admin", "member"] as const
29
+ export type OrgRole = (typeof ORG_ROLES)[number]
30
+
31
+ export const WORKSPACE_ROLES = ["admin", "member"] as const
32
+ export type WorkspaceRole = (typeof WORKSPACE_ROLES)[number]
33
+
34
+ export const MEMBER_STATUSES = ["active", "suspended"] as const
35
+ export type MemberStatus = (typeof MEMBER_STATUSES)[number]
36
+
37
+ /** `pending` = created, awaiting platform-admin approval. */
38
+ export const ORG_STATUSES = ["pending", "active", "suspended", "deleted"] as const
39
+ export type OrgStatus = (typeof ORG_STATUSES)[number]
40
+
41
+ /** An explicit per-workflow grant (works for personal workflows too). */
42
+ export const COLLABORATOR_ROLES = ["editor", "viewer"] as const
43
+ export type CollaboratorRole = (typeof COLLABORATOR_ROLES)[number]
44
+
45
+ export const WORKFLOW_VISIBILITIES = ["private", "workspace"] as const
46
+ export type WorkflowVisibility = (typeof WORKFLOW_VISIBILITIES)[number]
47
+
48
+ /** What an identity may do with a workflow, strongest first. */
49
+ export const ACCESS_LEVELS = ["own", "edit", "view", "none"] as const
50
+ export type AccessLevel = (typeof ACCESS_LEVELS)[number]
51
+
52
+ /** The access a setting may grant to a non-creator. */
53
+ export const GRANTED_ACCESS = ["view", "edit"] as const
54
+ export type GrantedAccess = (typeof GRANTED_ACCESS)[number]
55
+
56
+ export const SUBMISSION_STATUSES = ["submitted", "in_review", "returned", "approved"] as const
57
+ export type SubmissionStatus = (typeof SUBMISSION_STATUSES)[number]
58
+
59
+ /**
60
+ * Error codes the organization endpoints add to the standard envelope
61
+ * (`{ error: { code, message } }`). Clients dispatch on the code, never on
62
+ * the message text.
63
+ */
64
+ export const ORG_ERROR_CODES = [
65
+ "not_a_member",
66
+ "insufficient_role",
67
+ "org_not_active",
68
+ "member_suspended",
69
+ "workspace_archived",
70
+ "personal_space_disabled",
71
+ "token_workspace_mismatch",
72
+ "run_requires_authenticated_member",
73
+ "budget_exceeded",
74
+ "member_cap_exceeded",
75
+ "model_not_allowed",
76
+ "invitation_expired",
77
+ "invitation_revoked",
78
+ "email_mismatch",
79
+ "join_code_invalid",
80
+ "domain_not_allowed",
81
+ "already_started",
82
+ "collab_unavailable",
83
+ // Organization, workspace and membership endpoints.
84
+ "terms_required",
85
+ "not_org_member",
86
+ "already_a_member",
87
+ "owner_cannot_leave",
88
+ "has_active_workspaces",
89
+ // Invitations and join codes.
90
+ "invitation_not_found",
91
+ "invitation_accepted",
92
+ "bulk_invite_cap_exceeded",
93
+ ] as const
94
+ export type OrgErrorCode = (typeof ORG_ERROR_CODES)[number]
95
+
96
+ /**
97
+ * The settings every organization kind has a default for. `organizations.
98
+ * settings` and `workspaces.settings` store PARTIAL overrides of this shape;
99
+ * `resolveEffectiveSettings` (./settings.ts) produces the full one.
100
+ */
101
+ export const PresetSettingsSchema = z.object({
102
+ /** What org/workspace admins may do with a member's workflow. */
103
+ admin_access: z.enum(GRANTED_ACCESS),
104
+ default_workflow_visibility: z.enum(WORKFLOW_VISIBILITIES),
105
+ /** What a plain member may do with a `visibility = workspace` workflow. */
106
+ member_access_to_shared: z.enum(GRANTED_ACCESS),
107
+ members_can_create_projects: z.boolean(),
108
+ member_caps_enabled: z.boolean(),
109
+ /** Whether members keep a personal (non-workspace) space at all. */
110
+ personal_space_enabled: z.boolean(),
111
+ /** Whether a workspace admin may invite NEW people into the org. */
112
+ workspace_admins_can_invite: z.boolean(),
113
+ /** Whether an editor collaborator may invite further collaborators. */
114
+ collaborators_can_invite: z.boolean(),
115
+ /**
116
+ * When the organization is SUSPENDED, do its content rules still bind its
117
+ * members?
118
+ *
119
+ * Today this governs exactly one rule — `personal_space_enabled` — and that
120
+ * is not an accident: every other key above governs behaviour INSIDE a
121
+ * workspace, and a suspended organization grants no workspace context at
122
+ * all, so those are already moot.
123
+ *
124
+ * The name is general because an organization is deciding a principle here,
125
+ * not one checkbox's fate. Default `false`, which is today's behaviour: a
126
+ * suspended organization stops binding, and its members work independently
127
+ * until it resumes. An organization whose reason for disabling the personal
128
+ * space is contractual — the work made here belongs to the institution —
129
+ * turns this on, because an unpaid invoice does not void a contract.
130
+ */
131
+ policy_survives_suspension: z.boolean(),
132
+ })
133
+ export type PresetSettings = z.infer<typeof PresetSettingsSchema>
134
+ export type PresetSettingKey = keyof PresetSettings
135
+ export const PRESET_SETTING_KEYS = Object.freeze(
136
+ Object.keys(PresetSettingsSchema.shape) as readonly PresetSettingKey[],
137
+ )
138
+
139
+ /** `workspaces.settings` — per-workspace overrides only. */
140
+ export const WorkspaceSettingsSchema = PresetSettingsSchema.partial()
141
+ export type WorkspaceSettings = z.infer<typeof WorkspaceSettingsSchema>
142
+
143
+ const EMAIL_DOMAIN_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/
144
+
145
+ /** `organizations.settings` — preset overrides plus org-only keys. */
146
+ export const OrgSettingsSchema = PresetSettingsSchema.partial().extend({
147
+ /** Lower-case domains that join codes / domain auto-join accept. Empty = any. */
148
+ allowed_email_domains: z.array(z.string().regex(EMAIL_DOMAIN_PATTERN)).optional(),
149
+ /** Per-org relabelling of the kind vocabulary (./vocabulary.ts). */
150
+ vocabulary_overrides: z.record(z.string(), z.string()).optional(),
151
+ })
152
+ export type OrgSettings = z.infer<typeof OrgSettingsSchema>