@opengeni/core 0.2.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.
@@ -0,0 +1,186 @@
1
+ import type { Settings } from "@opengeni/config";
2
+ import { verifyDelegatedAccessToken, type AccessContext, type AccessGrant, type Permission } from "@opengeni/contracts";
3
+ import {
4
+ bootstrapWorkspace,
5
+ ensureManagedAccessForUser,
6
+ findActiveApiKeyByHash,
7
+ getWorkspaceGrant,
8
+ requireWorkspace,
9
+ type Database,
10
+ } from "@opengeni/db";
11
+ import type { Context } from "hono";
12
+ import { HTTPException } from "hono/http-exception";
13
+ import type { ManagedAuth } from "../managed-auth-type";
14
+
15
+ const bearerPrefix = "Bearer ";
16
+
17
+ export type AccessDeps = {
18
+ db: Database;
19
+ settings: Settings;
20
+ managedAuth?: ManagedAuth | null;
21
+ };
22
+
23
+ export async function requireAccessContext(c: Context, deps: AccessDeps): Promise<AccessContext> {
24
+ const context = await resolveAccessContext(c, deps);
25
+ if (!context) {
26
+ throw new HTTPException(401, { message: "authentication required" });
27
+ }
28
+ return context;
29
+ }
30
+
31
+ export async function requireAccessGrant(c: Context, deps: AccessDeps, workspaceId: string, permission?: Permission): Promise<AccessGrant> {
32
+ const context = await requireAccessContext(c, deps);
33
+ const grant = context.workspaceGrants.find((candidate) => candidate.workspaceId === workspaceId)
34
+ ?? await getWorkspaceGrant(deps.db, context.subjectId, workspaceId);
35
+ if (!grant) {
36
+ const workspace = await requireWorkspace(deps.db, workspaceId).catch(() => null);
37
+ if (!workspace) {
38
+ throw new HTTPException(404, { message: "workspace not found" });
39
+ }
40
+ throw new HTTPException(403, { message: "workspace access denied" });
41
+ }
42
+ if (permission) {
43
+ requirePermission(grant, permission);
44
+ }
45
+ return grant;
46
+ }
47
+
48
+ export function requirePermission(grant: AccessGrant, permission: Permission): void {
49
+ if (!hasPermission(grant.permissions, permission)) {
50
+ throw new HTTPException(403, { message: `missing permission: ${permission}` });
51
+ }
52
+ }
53
+
54
+ export function hasPermission(permissions: Permission[], permission: Permission): boolean {
55
+ return permissions.includes(permission) || permissions.includes("workspace:admin");
56
+ }
57
+
58
+ async function resolveAccessContext(c: Context, deps: AccessDeps): Promise<AccessContext | null> {
59
+ if (deps.settings.productAccessMode === "local") {
60
+ return await bootstrapWorkspace(deps.db, {
61
+ accountExternalSource: "opengeni:local",
62
+ accountExternalId: "default",
63
+ accountName: "Local",
64
+ workspaceExternalSource: "opengeni:local",
65
+ workspaceExternalId: "default",
66
+ workspaceName: "Local",
67
+ subjectId: "dev",
68
+ subjectLabel: "Local dev",
69
+ });
70
+ }
71
+
72
+ if (deps.settings.productAccessMode === "configured") {
73
+ const delegated = await delegatedAccessContext(c, deps, "configured");
74
+ if (delegated) {
75
+ return delegated;
76
+ }
77
+ if (deps.settings.delegationSecret) {
78
+ return null;
79
+ }
80
+ return await bootstrapWorkspace(deps.db, {
81
+ accountExternalSource: "opengeni:configured",
82
+ accountExternalId: "default",
83
+ accountName: "Configured",
84
+ workspaceExternalSource: "opengeni:configured",
85
+ workspaceExternalId: "default",
86
+ workspaceName: "Configured",
87
+ subjectId: configuredSubject(c),
88
+ subjectLabel: "Configured key",
89
+ });
90
+ }
91
+
92
+ const bearer = bearerToken(c);
93
+ if (bearer) {
94
+ const delegated = await delegatedAccessContext(c, deps, "managed", bearer);
95
+ if (delegated) {
96
+ return delegated;
97
+ }
98
+ const apiKey = await findActiveApiKeyByHash(deps.db, await sha256Hex(bearer));
99
+ if (apiKey) {
100
+ const accountPermissions = apiKey.workspaceId
101
+ ? apiKey.permissions.filter((permission) => permission === "billing:read" || permission === "billing:manage")
102
+ : apiKey.permissions;
103
+ return {
104
+ mode: "managed",
105
+ subjectId: `api_key:${apiKey.id}`,
106
+ subjectLabel: apiKey.name,
107
+ accountGrants: [{
108
+ accountId: apiKey.accountId,
109
+ subjectId: `api_key:${apiKey.id}`,
110
+ subjectLabel: apiKey.name,
111
+ permissions: accountPermissions,
112
+ }],
113
+ workspaceGrants: apiKey.workspaceId ? [{
114
+ workspaceId: apiKey.workspaceId,
115
+ accountId: apiKey.accountId,
116
+ subjectId: `api_key:${apiKey.id}`,
117
+ subjectLabel: apiKey.name,
118
+ permissions: apiKey.permissions,
119
+ }] : [],
120
+ defaultAccountId: apiKey.accountId,
121
+ defaultWorkspaceId: apiKey.workspaceId,
122
+ } satisfies AccessContext;
123
+ }
124
+ }
125
+
126
+ if (deps.managedAuth) {
127
+ const session = await deps.managedAuth.api.getSession({ headers: c.req.raw.headers });
128
+ if (session?.user) {
129
+ return await ensureManagedAccessForUser(deps.db, {
130
+ userId: session.user.id,
131
+ email: session.user.email,
132
+ name: session.user.name,
133
+ });
134
+ }
135
+ }
136
+
137
+ return null;
138
+ }
139
+
140
+ async function delegatedAccessContext(c: Context, deps: AccessDeps, mode: "configured" | "managed", token = bearerToken(c)): Promise<AccessContext | null> {
141
+ if (!token || !deps.settings.delegationSecret) {
142
+ return null;
143
+ }
144
+ const payload = await verifyDelegatedAccessToken(deps.settings.delegationSecret, token);
145
+ if (!payload) {
146
+ return null;
147
+ }
148
+ return {
149
+ mode,
150
+ subjectId: payload.subjectId,
151
+ ...(payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {}),
152
+ accountGrants: [{
153
+ accountId: payload.accountId,
154
+ subjectId: payload.subjectId,
155
+ ...(payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {}),
156
+ permissions: payload.permissions,
157
+ }],
158
+ workspaceGrants: [{
159
+ workspaceId: payload.workspaceId,
160
+ accountId: payload.accountId,
161
+ subjectId: payload.subjectId,
162
+ ...(payload.subjectLabel ? { subjectLabel: payload.subjectLabel } : {}),
163
+ permissions: payload.permissions,
164
+ // sessionId is worker-asserted (HMAC-signed token claim), not agent
165
+ // controlled; it scopes session-bound MCP tools such as goal management.
166
+ metadata: { delegated: true, ...(payload.sessionId ? { sessionId: payload.sessionId } : {}) },
167
+ }],
168
+ defaultAccountId: payload.accountId,
169
+ defaultWorkspaceId: payload.workspaceId,
170
+ };
171
+ }
172
+
173
+ function configuredSubject(c: Context): string {
174
+ const header = c.req.header("x-opengeni-subject");
175
+ return header && header.trim().length > 0 ? `configured:${header.trim()}` : "configured:key";
176
+ }
177
+
178
+ function bearerToken(c: Context): string | null {
179
+ const authorization = c.req.header("authorization");
180
+ return authorization?.startsWith(bearerPrefix) ? authorization.slice(bearerPrefix.length) : null;
181
+ }
182
+
183
+ async function sha256Hex(value: string): Promise<string> {
184
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
185
+ return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
186
+ }
@@ -0,0 +1,207 @@
1
+ import { configuredStaticUsageLimits } from "@opengeni/config";
2
+ import type { LimitAction, LimitDecision } from "@opengeni/contracts";
3
+ import {
4
+ countActiveApiKeysForWorkspace,
5
+ countScheduledTasksForWorkspace,
6
+ countWorkspacesForAccount,
7
+ getBillingBalance,
8
+ isCodexBilledTurn,
9
+ recordUsageEvent,
10
+ sumUsageQuantity,
11
+ } from "@opengeni/db";
12
+ import { HTTPException } from "hono/http-exception";
13
+ import type { ApiRouteDeps } from "../dependencies";
14
+
15
+ export type LimitCheckInput = {
16
+ accountId: string;
17
+ workspaceId?: string;
18
+ action: LimitAction;
19
+ quantity?: number;
20
+ // The turn's model id, when the action represents an agent turn. When this is a
21
+ // Codex-billed turn (codex/<slug> + feature enabled + active workspace
22
+ // credential) the turn is paid by the user's ChatGPT/Codex plan and consumes
23
+ // ZERO OpenGeni credits, so the credit-balance + model-cost + token gates are
24
+ // skipped. Non-model infra actions (workspace/api_key/schedule create) leave
25
+ // this undefined and are unaffected.
26
+ model?: string | null;
27
+ };
28
+
29
+ export async function requireLimit(deps: ApiRouteDeps, input: LimitCheckInput): Promise<void> {
30
+ const decision = await checkLimit(deps, input);
31
+ if (decision.allowed) {
32
+ return;
33
+ }
34
+ throw new HTTPException(decision.code === "insufficient_credits" ? 402 : 429, { message: decision.message });
35
+ }
36
+
37
+ export async function checkLimit(deps: ApiRouteDeps, input: LimitCheckInput): Promise<LimitDecision> {
38
+ // Resolve the canonical codex-billed predicate ONCE. Returns false for any
39
+ // action that carries no model (infra caps) or any codex/<slug> model without
40
+ // an active credential — so the bypass never triggers on the prefix alone.
41
+ const codexBilled = input.workspaceId
42
+ ? await isCodexBilledTurn({ db: deps.db, settings: deps.settings, workspaceId: input.workspaceId, model: input.model })
43
+ : false;
44
+ const creditDecision = await checkCreditBalance(deps, input, codexBilled);
45
+ if (!creditDecision.allowed) {
46
+ return creditDecision;
47
+ }
48
+ if (deps.settings.usageLimitsMode !== "static" && deps.settings.usageLimitsMode !== "managed") {
49
+ return { allowed: true };
50
+ }
51
+ return await checkStaticCaps(deps, input, codexBilled);
52
+ }
53
+
54
+ async function checkCreditBalance(deps: ApiRouteDeps, input: LimitCheckInput, codexBilled: boolean): Promise<LimitDecision> {
55
+ if (codexBilled) {
56
+ return { allowed: true }; // paid by the user's ChatGPT/Codex plan — zero OpenGeni credits
57
+ }
58
+ if (!usesCreditLimits(deps) || !isCostlyAction(input.action)) {
59
+ return { allowed: true };
60
+ }
61
+ const balance = await getBillingBalance(deps.db, input.accountId);
62
+ if (balance.balanceMicros > 0) {
63
+ return { allowed: true };
64
+ }
65
+ return { allowed: false, code: "insufficient_credits", message: "insufficient OpenGeni credits" };
66
+ }
67
+
68
+ async function checkStaticCaps(deps: ApiRouteDeps, input: LimitCheckInput, codexBilled: boolean): Promise<LimitDecision> {
69
+ const limits = configuredStaticUsageLimits(deps.settings);
70
+ if (limits.maxMonthlyCostMicrosPerAccount && isCostlyAction(input.action) && !codexBilled) {
71
+ const used = await sumUsageQuantity(deps.db, {
72
+ accountId: input.accountId,
73
+ eventType: "model.cost",
74
+ since: startOfUtcMonth(),
75
+ });
76
+ if (used >= limits.maxMonthlyCostMicrosPerAccount) {
77
+ return blocked("max_monthly_cost_micros_per_account", `monthly model cost limit reached (${limits.maxMonthlyCostMicrosPerAccount} micros)`);
78
+ }
79
+ }
80
+ switch (input.action) {
81
+ case "workspace:create": {
82
+ if (!limits.maxWorkspacesPerAccount) {
83
+ return { allowed: true };
84
+ }
85
+ const count = await countWorkspacesForAccount(deps.db, input.accountId);
86
+ return count < limits.maxWorkspacesPerAccount
87
+ ? { allowed: true }
88
+ : blocked("max_workspaces_per_account", `workspace limit reached (${limits.maxWorkspacesPerAccount})`);
89
+ }
90
+ case "api_key:create": {
91
+ if (!limits.maxApiKeysPerWorkspace || !input.workspaceId) {
92
+ return { allowed: true };
93
+ }
94
+ const count = await countActiveApiKeysForWorkspace(deps.db, input.workspaceId);
95
+ return count < limits.maxApiKeysPerWorkspace
96
+ ? { allowed: true }
97
+ : blocked("max_api_keys_per_workspace", `API key limit reached (${limits.maxApiKeysPerWorkspace})`);
98
+ }
99
+ case "schedule:create": {
100
+ if (!limits.maxSchedulesPerWorkspace || !input.workspaceId) {
101
+ return { allowed: true };
102
+ }
103
+ const count = await countScheduledTasksForWorkspace(deps.db, input.workspaceId);
104
+ return count < limits.maxSchedulesPerWorkspace
105
+ ? { allowed: true }
106
+ : blocked("max_schedules_per_workspace", `scheduled task limit reached (${limits.maxSchedulesPerWorkspace})`);
107
+ }
108
+ case "file:upload": {
109
+ if (!limits.maxFileUploadBytes || !input.quantity) {
110
+ return { allowed: true };
111
+ }
112
+ return input.quantity <= limits.maxFileUploadBytes
113
+ ? { allowed: true }
114
+ : blocked("max_file_upload_bytes", `file upload exceeds static limit of ${limits.maxFileUploadBytes} bytes`);
115
+ }
116
+ case "agent_run:create": {
117
+ if (!limits.maxMonthlyAgentRunsPerWorkspace || !input.workspaceId) {
118
+ return { allowed: true };
119
+ }
120
+ const used = await sumUsageQuantity(deps.db, {
121
+ workspaceId: input.workspaceId,
122
+ eventType: "agent_run.created",
123
+ since: startOfUtcMonth(),
124
+ });
125
+ const requested = input.quantity ?? 0;
126
+ return used + requested <= limits.maxMonthlyAgentRunsPerWorkspace
127
+ ? { allowed: true }
128
+ : blocked("max_monthly_agent_runs_per_workspace", `monthly agent run limit reached (${limits.maxMonthlyAgentRunsPerWorkspace})`);
129
+ }
130
+ case "tokens:consume": {
131
+ if (codexBilled || !limits.maxMonthlyTokensPerWorkspace || !input.workspaceId) {
132
+ return { allowed: true };
133
+ }
134
+ const used = await sumUsageQuantity(deps.db, {
135
+ workspaceId: input.workspaceId,
136
+ eventType: "model.tokens",
137
+ since: startOfUtcMonth(),
138
+ });
139
+ const requested = input.quantity ?? 0;
140
+ return used + requested <= limits.maxMonthlyTokensPerWorkspace
141
+ ? { allowed: true }
142
+ : blocked("max_monthly_tokens_per_workspace", `monthly token limit reached (${limits.maxMonthlyTokensPerWorkspace})`);
143
+ }
144
+ case "document:index": {
145
+ if (!limits.maxDocumentIndexedChunksPerWorkspace || !input.workspaceId) {
146
+ return { allowed: true };
147
+ }
148
+ const used = await sumUsageQuantity(deps.db, {
149
+ workspaceId: input.workspaceId,
150
+ eventType: "document.indexed",
151
+ since: startOfUtcMonth(),
152
+ });
153
+ const requested = input.quantity ?? 0;
154
+ return used + requested <= limits.maxDocumentIndexedChunksPerWorkspace
155
+ ? { allowed: true }
156
+ : blocked("max_document_indexed_chunks_per_workspace", `monthly document indexing limit reached (${limits.maxDocumentIndexedChunksPerWorkspace} chunks)`);
157
+ }
158
+ }
159
+ }
160
+
161
+ export async function recordWorkspaceUsage(deps: ApiRouteDeps, input: {
162
+ accountId: string;
163
+ workspaceId: string;
164
+ subjectId?: string | null;
165
+ eventType:
166
+ | "agent_run.created"
167
+ | "file.uploaded"
168
+ | "document.indexed"
169
+ | "scheduled_task.fired";
170
+ quantity: number;
171
+ unit: string;
172
+ sourceResourceType: string;
173
+ sourceResourceId: string;
174
+ idempotencyKey: string;
175
+ }): Promise<void> {
176
+ await recordUsageEvent(deps.db, {
177
+ accountId: input.accountId,
178
+ workspaceId: input.workspaceId,
179
+ subjectId: input.subjectId ?? null,
180
+ eventType: input.eventType,
181
+ quantity: input.quantity,
182
+ unit: input.unit,
183
+ sourceResourceType: input.sourceResourceType,
184
+ sourceResourceId: input.sourceResourceId,
185
+ idempotencyKey: input.idempotencyKey,
186
+ });
187
+ }
188
+
189
+ function usesCreditLimits(deps: ApiRouteDeps): boolean {
190
+ return deps.settings.billingMode === "stripe" || deps.settings.usageLimitsMode === "managed";
191
+ }
192
+
193
+ function isCostlyAction(action: LimitAction): boolean {
194
+ return action === "agent_run:create"
195
+ || action === "tokens:consume"
196
+ || action === "file:upload"
197
+ || action === "document:index";
198
+ }
199
+
200
+ function blocked(code: string, message: string): LimitDecision {
201
+ return { allowed: false, code, message };
202
+ }
203
+
204
+ function startOfUtcMonth(): Date {
205
+ const now = new Date();
206
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
207
+ }
@@ -0,0 +1,70 @@
1
+ import type { Settings } from "@opengeni/config";
2
+ import type { Document, ScheduledTask } from "@opengeni/contracts";
3
+ import type { Database } from "@opengeni/db";
4
+ import type { DocumentServices } from "@opengeni/documents";
5
+ import type { EventBus } from "@opengeni/events";
6
+ import type { Observability } from "@opengeni/observability";
7
+ import type { createObjectStorage } from "@opengeni/storage";
8
+ import type { ManagedAuth } from "./managed-auth-type";
9
+ import type { ApiSandboxClient, ResumeBoxByIdInput, ResumedSandboxSession } from "./sandbox-types";
10
+
11
+ export type SessionWorkflowClient = {
12
+ signalUserMessage: (input: { sessionId: string; eventId: string; workflowId: string }) => Promise<void>;
13
+ wakeSessionWorkflow: (input: { accountId: string; workspaceId: string; sessionId: string; workflowId: string }) => Promise<void>;
14
+ signalApprovalDecision: (input: { sessionId: string; eventId: string; workflowId: string }) => Promise<void>;
15
+ // Interrupt must reach the workflow whether or not an execution is currently
16
+ // running: a long-lived session that has gone idle (its workflow returned
17
+ // after markSessionIdle) has NO running execution, so a plain
18
+ // getHandle().signal() throws WorkflowNotFoundError -> a 500 that leaves an
19
+ // operator unable to stop a session. signalWithStart start-or-signals, so it
20
+ // needs the session-workflow args (accountId/workspaceId) to start a fresh
21
+ // run when none is live; the buffered `interrupt` signal is then honored by
22
+ // the workflow's idle-interrupt path (pause goal + mark idle).
23
+ signalInterrupt: (input: { accountId: string; workspaceId: string; sessionId: string; eventId: string; workflowId: string }) => Promise<void>;
24
+ syncScheduledTask: (input: { task: ScheduledTask }) => Promise<void>;
25
+ deleteScheduledTaskSchedule: (input: { temporalScheduleId: string }) => Promise<void>;
26
+ triggerScheduledTask: (input: { task: ScheduledTask; agentRunUsageIdempotencyKey?: string; triggerWorkflowId?: string }) => Promise<void>;
27
+ };
28
+
29
+ export type DocumentIndexClient = {
30
+ indexDocument: (input: { accountId: string; workspaceId: string; documentId: string }) => Promise<Document | void>;
31
+ };
32
+
33
+ export type AppDependencies = {
34
+ settings: Settings;
35
+ db: Database;
36
+ bus: EventBus;
37
+ workflowClient: SessionWorkflowClient;
38
+ documentIndexer?: DocumentIndexClient;
39
+ documentServices?: DocumentServices;
40
+ observability?: Observability;
41
+ githubStateSecret?: string;
42
+ managedAuth?: ManagedAuth | null;
43
+ // The API process's OWN agent-loop-free sandbox client (constructed from
44
+ // settings via @opengeni/runtime/sandbox). Undefined when sandboxBackend=none.
45
+ // This is the foundation of the API-direct control plane: the API resumes
46
+ // boxes by id in-process, no Temporal/worker for non-turn ops. Optional on
47
+ // construction (createApp builds it from settings when absent) so existing
48
+ // tests that pass a minimal deps bag keep working.
49
+ sandboxClient?: ApiSandboxClient;
50
+ /**
51
+ * Resume a box by id from a serialized resume_state envelope (the lease's
52
+ * `resume_state` + `resume_backend_id` from P1.1) and return a live session
53
+ * for a single in-process op. resume → use → drop; the lease owns lifecycle,
54
+ * the returned handle does NOT own the box. Throws SandboxResumeError on a
55
+ * backend mismatch or a resume failure.
56
+ */
57
+ resumeBoxById?: (input: ResumeBoxByIdInput) => Promise<ResumedSandboxSession>;
58
+ };
59
+
60
+ export type ObjectStorageDependency = ReturnType<typeof createObjectStorage>;
61
+
62
+ export type ApiRouteDeps = AppDependencies & {
63
+ objectStorage: ObjectStorageDependency;
64
+ githubStateSecret: string;
65
+ documentIndexer: DocumentIndexClient;
66
+ getDocumentServices: () => DocumentServices;
67
+ // Resolved by createApp from settings: routes always get a concrete
68
+ // resumeBoxById (it throws SandboxResumeError when sandboxBackend=none).
69
+ resumeBoxById: (input: ResumeBoxByIdInput) => Promise<ResumedSandboxSession>;
70
+ };