@k2b/cloud 0.41.0 → 0.42.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 +6 -5
- package/src/_internal/bounded-json.ts +14 -2
- package/src/_internal/capabilities.ts +87 -8
- package/src/_internal/capability-reviews.ts +6 -0
- package/src/_internal/capability-timeout.ts +71 -0
- package/src/_internal/define-app.ts +145 -89
- package/src/_internal/http-drain.ts +56 -0
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.tsx +16 -17
- package/src/access/PrincipalPicker.tsx +45 -10
- package/src/access/messages.ts +8 -0
- package/src/access/principal-label.ts +18 -0
- package/src/access/ui.ts +1 -0
- package/src/account/EntitySearch.tsx +5 -4
- package/src/ai/action-review.ts +2 -1
- package/src/ai/app-skill-store.ts +1 -1
- package/src/ai/capabilities.ts +21 -3
- package/src/ai/capability-execution.ts +14 -12
- package/src/ai/chat/action-card.ts +7 -1
- package/src/ai/chat/action-messages.ts +14 -0
- package/src/ai/chat/action-question.browser-harness.tsx +12 -0
- package/src/ai/chat/action-question.tsx +202 -22
- package/src/ai/chat/action-text.ts +1 -1
- package/src/ai/chat-tasks.ts +2 -1
- package/src/ai/client/controller.ts +150 -32
- package/src/ai/client/transport.ts +14 -2
- package/src/ai/code-capability-routes.ts +27 -23
- package/src/ai/code-runtime-tools.ts +14 -16
- package/src/ai/code-source-tools.ts +16 -18
- package/src/ai/executor.ts +56 -21
- package/src/ai/fetch-file-tool.ts +67 -76
- package/src/ai/http.ts +1 -0
- package/src/ai/migrate.ts +13 -0
- package/src/ai/open-questions.ts +4 -8
- package/src/ai/projects.ts +1 -2
- package/src/ai/protocol.ts +2 -2
- package/src/ai/recent-resources.ts +12 -5
- package/src/ai/review-content.ts +77 -0
- package/src/ai/routes.ts +69 -31
- package/src/ai/runtime.ts +1 -0
- package/src/ai/skill-tool.ts +17 -6
- package/src/ai/skills-routes.ts +72 -27
- package/src/ai/skills.ts +139 -60
- package/src/ai/store.ts +168 -33
- package/src/ai/types.ts +10 -3
- package/src/ai/usage.ts +2 -2
- package/src/api/admin-core-settings.ts +15 -2
- package/src/api/admin-geo.ts +54 -0
- package/src/api/admin-linux-identities.ts +6 -3
- package/src/api/admin-outgoing-mail.ts +29 -15
- package/src/api/approvals-loader.ts +3 -3
- package/src/api/approvals.ts +51 -0
- package/src/api/apps.ts +4 -2
- package/src/api/capabilities.ts +258 -181
- package/src/api/capability-streams.ts +21 -13
- package/src/api/cli-plugins.ts +4 -1
- package/src/api/geo-glyphs.ts +11 -0
- package/src/api/geo.ts +128 -0
- package/src/api/help.ts +12 -4
- package/src/api/index.ts +5 -0
- package/src/api/mcp.ts +46 -38
- package/src/api/me-panel-consent.ts +8 -5
- package/src/api/search.ts +21 -14
- package/src/api/sync-ops.ts +19 -13
- package/src/api/widgets.ts +42 -18
- package/src/approvals/Approvals.tsx +5 -3
- package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
- package/src/browser/CloudResourceSearch.tsx +3 -13
- package/src/browser/PanelLeaveNotice.tsx +18 -0
- package/src/browser/navigation-search.ts +78 -25
- package/src/browser/panel-leave.ts +69 -0
- package/src/browser/panel-messages.ts +12 -0
- package/src/browser/panel-slot.ts +173 -24
- package/src/browser/panel.ts +3 -0
- package/src/browser/resource-search-messages.ts +2 -0
- package/src/capabilities/client.ts +28 -1
- package/src/capabilities/executions.ts +22 -3
- package/src/capabilities/geo-client.ts +57 -0
- package/src/capabilities/response.ts +2 -1
- package/src/capabilities/review-storage.ts +24 -0
- package/src/capabilities/reviews.ts +17 -3
- package/src/capabilities/server.ts +36 -16
- package/src/cli/access.ts +15 -23
- package/src/cli/admin/linux.ts +13 -2
- package/src/cli/admin/outgoing-mail.ts +26 -4
- package/src/cli/capabilities.ts +19 -0
- package/src/config/env.ts +0 -6
- package/src/contracts/app-access.ts +48 -0
- package/src/contracts/app.ts +4 -1
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/avatar.ts +186 -0
- package/src/contracts/capabilities.ts +148 -54
- package/src/contracts/geo-admin.ts +127 -0
- package/src/contracts/geo-map.ts +19 -0
- package/src/contracts/geo-route.ts +168 -0
- package/src/contracts/geo.ts +248 -0
- package/src/contracts/index.ts +4 -0
- package/src/contracts/outgoing-mail.ts +17 -4
- package/src/contracts/registry.ts +2 -0
- package/src/events/app-access.ts +68 -0
- package/src/events/live-delivery.ts +128 -0
- package/src/events/live.ts +14 -130
- package/src/geo/CloudMap.tsx +95 -0
- package/src/geo/map-messages.ts +17 -0
- package/src/geo/ui.ts +1 -0
- package/src/index.ts +2 -0
- package/src/server/client-ip.ts +36 -0
- package/src/server/index.ts +3 -4
- package/src/server/middleware/app-access.ts +312 -0
- package/src/server/middleware/auth.ts +1 -1
- package/src/server/middleware/invocation.ts +11 -0
- package/src/server/middleware/rate-limit.ts +4 -4
- package/src/server/services/access.ts +13 -9
- package/src/server/services/index.ts +0 -2
- package/src/server/services/services.ts +0 -2
- package/src/services/accounts/posix.ts +21 -7
- package/src/services/allowed-network-fetch.ts +16 -10
- package/src/services/app-access-metrics.ts +24 -0
- package/src/services/app-access.ts +123 -0
- package/src/services/app-discovery.ts +27 -0
- package/src/services/approvals/content.ts +40 -0
- package/src/services/approvals/index.ts +14 -6
- package/src/services/approvals/list.ts +6 -11
- package/src/services/approvals/question.ts +11 -7
- package/src/services/downstream-budget.ts +87 -0
- package/src/services/geo/admin.ts +262 -0
- package/src/services/geo/bucket.ts +67 -0
- package/src/services/geo/cache.ts +57 -0
- package/src/services/geo/costs.ts +5 -0
- package/src/services/geo/errors.ts +41 -0
- package/src/services/geo/fixtures/README.md +17 -0
- package/src/services/geo/fixtures/along.json +355 -0
- package/src/services/geo/fixtures/autocomplete.json +218 -0
- package/src/services/geo/fixtures/geocode.json +129 -0
- package/src/services/geo/fixtures/places.json +355 -0
- package/src/services/geo/fixtures/reverse.json +61 -0
- package/src/services/geo/fixtures/routing.json +57 -0
- package/src/services/geo/geoapify-route.ts +93 -0
- package/src/services/geo/geoapify.ts +281 -0
- package/src/services/geo/ledger.ts +122 -0
- package/src/services/geo/map.ts +160 -0
- package/src/services/geo/migrate.ts +25 -0
- package/src/services/geo/polyline.ts +184 -0
- package/src/services/geo/route-chain.ts +145 -0
- package/src/services/geo/route-costs.ts +9 -0
- package/src/services/geo/route-geometry.ts +39 -0
- package/src/services/geo/route-plan.ts +172 -0
- package/src/services/geo/route-service.ts +433 -0
- package/src/services/geo/service.ts +300 -0
- package/src/services/geo/settings.ts +49 -0
- package/src/services/help/index.ts +16 -7
- package/src/services/help/types.ts +6 -1
- package/src/services/identity/invocation-actor.ts +20 -2
- package/src/services/identity/issue-invocation.ts +67 -0
- package/src/services/identity/key-ring.ts +5 -3
- package/src/services/identity/runtime-config.ts +23 -0
- package/src/services/index.ts +9 -0
- package/src/services/mandates/index.ts +40 -3
- package/src/services/network-security.ts +6 -3
- package/src/services/network-transport.ts +99 -0
- package/src/services/notifications/catalog.ts +3 -2
- package/src/services/notifications/runtime.ts +1 -0
- package/src/services/notifications/web-push-transport.ts +45 -38
- package/src/services/outgoing-mail/dispatcher.ts +6 -1
- package/src/services/outgoing-mail/index.ts +55 -1
- package/src/services/outgoing-mail/messages.ts +82 -3
- package/src/services/outgoing-mail/resolve-host.ts +2 -2
- package/src/services/outgoing-mail/retention.ts +13 -1
- package/src/services/outgoing-mail/store.ts +36 -6
- package/src/services/outgoing-mail/test-send.ts +34 -4
- package/src/services/outgoing-mail/transport.ts +1 -1
- package/src/services/pdf/gotenberg.ts +11 -38
- package/src/services/pdf/template-preview.ts +1 -1
- package/src/services/public-http.ts +28 -40
- package/src/services/settings/app.ts +4 -1
- package/src/services/settings/core-settings.ts +88 -0
- package/src/services/settings/defaults.ts +8 -2
- package/src/services/settings/errors.ts +11 -0
- package/src/services/settings/geo-budget.ts +40 -0
- package/src/services/settings/index.ts +4 -3
- package/src/services/settings/store.ts +25 -17
- package/src/services/weather/geo.ts +29 -111
- package/src/services/weather/index.ts +4 -3
- package/src/services/weather/locations.ts +8 -5
- package/src/services/weather/migrate.ts +1 -0
- package/src/services/weather/settings.ts +0 -16
- package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
- package/src/shared/avatar-art.ts +400 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/network-address.ts +22 -3
- package/src/shared/template-rendering.ts +36 -4
- package/src/shared-text/documents.ts +1 -1
- package/src/shared-text/index.ts +2 -0
- package/src/shared-text/log.ts +51 -3
- package/src/shared-text/snapshots.ts +340 -0
- package/src/ssr/AdminLayout.tsx +4 -2
- package/src/ssr/CloudShell.island.tsx +6 -0
- package/src/ssr/Layout.tsx +65 -15
- package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
- package/src/ssr/LayoutHelp.tsx +3 -2
- package/src/ssr/PanelRuntime.island.tsx +11 -7
- package/src/ssr/PwaLayout.tsx +2 -2
- package/src/ssr/RailApps.tsx +6 -0
- package/src/ssr/ShellRuntime.island.tsx +23 -7
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +6 -6
- package/src/ssr/admin-navigation.ts +1 -0
- package/src/ssr/app-badges.ts +6 -1
- package/src/ssr/compact-shell.browser-harness.ts +44 -5
- package/src/ssr/help.ts +3 -2
- package/src/ssr/index.ts +1 -1
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/mobile-menu-history.ts +4 -0
- package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
- package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
- package/src/ssr/page-swap.browser-harness.tsx +177 -0
- package/src/ssr/page-swap.ts +28 -0
- package/src/ssr/page-trail-runtime.ts +32 -13
- package/src/ssr/panel-consent.ts +2 -2
- package/src/ssr/panel-slot.browser-harness.tsx +3 -0
- package/src/ssr/platform-messages.ts +8 -0
- package/src/ssr/runtime.ts +17 -0
- package/src/ssr/shell-data.tsx +10 -2
- package/src/styles/global.css +70 -6
- package/src/server/services/geo.ts +0 -215
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { AccessSubject, CapabilityExecutionContext, RequestActor } from "../../contracts";
|
|
3
|
+
import {
|
|
4
|
+
GeoAutocompleteDataSchema,
|
|
5
|
+
type GeoAutocompleteInput,
|
|
6
|
+
GeoAutocompleteInputSchema,
|
|
7
|
+
type GeoFunction,
|
|
8
|
+
GeoGeocodeDataSchema,
|
|
9
|
+
type GeoGeocodeInput,
|
|
10
|
+
GeoGeocodeInputSchema,
|
|
11
|
+
GeoPlaceSearchDataSchema,
|
|
12
|
+
type GeoPlaceSearchInput,
|
|
13
|
+
GeoPlaceSearchInputSchema,
|
|
14
|
+
GeoReverseDataSchema,
|
|
15
|
+
type GeoReverseInput,
|
|
16
|
+
GeoReverseInputSchema,
|
|
17
|
+
} from "../../contracts/geo";
|
|
18
|
+
import { createGeoBucket, GEO_TIMEOUT_MS } from "./bucket";
|
|
19
|
+
import { createGeoCache, GEO_MAX_JSON_BYTES, geoCacheKey } from "./cache";
|
|
20
|
+
import { geoReferenceCost, geoReserveCost } from "./costs";
|
|
21
|
+
import { GeoError, GeoProviderError, limitExceeded } from "./errors";
|
|
22
|
+
import { GeoapifyAdapter } from "./geoapify";
|
|
23
|
+
import { createGeoLedger, geoPeriods } from "./ledger";
|
|
24
|
+
import { cutGeoWindow, decodePolyline6, locateOnGeoLine, simplifyGeoLine } from "./polyline";
|
|
25
|
+
import { createGeoRouteService } from "./route-service";
|
|
26
|
+
import { type GeoConfig, geoPlan, readGeoConfig } from "./settings";
|
|
27
|
+
|
|
28
|
+
export type GeoContext = Partial<Pick<CapabilityExecutionContext, "actor" | "accessSubject" | "locale" | "signal" | "requestId">>;
|
|
29
|
+
export const geoIdentity = (context: GeoContext): { actor: RequestActor; accessSubject: AccessSubject } => {
|
|
30
|
+
const { actor, accessSubject } = context;
|
|
31
|
+
if (
|
|
32
|
+
!actor ||
|
|
33
|
+
!accessSubject ||
|
|
34
|
+
!(actor.kind === "user" ? actor.user?.id : actor.serviceAccount?.id) ||
|
|
35
|
+
!(accessSubject.type === "user" ? accessSubject.userId : accessSubject.serviceAccountId)
|
|
36
|
+
)
|
|
37
|
+
throw new GeoError("GEO_FORBIDDEN");
|
|
38
|
+
return { actor, accessSubject };
|
|
39
|
+
};
|
|
40
|
+
const bound = (name: string, value: number, min: number, max: number, integer = false) => {
|
|
41
|
+
if (!Number.isFinite(value) || value > max || value < min || (integer && !Number.isInteger(value)))
|
|
42
|
+
limitExceeded(name, value < min ? min : max, value);
|
|
43
|
+
};
|
|
44
|
+
const parse = <T>(schema: z.ZodType<T>, input: unknown): T => {
|
|
45
|
+
const parsed = schema.safeParse(input);
|
|
46
|
+
if (!parsed.success) throw new GeoError("GEO_UNSUPPORTED_OPTION");
|
|
47
|
+
return parsed.data;
|
|
48
|
+
};
|
|
49
|
+
const country = (value?: string) => {
|
|
50
|
+
if (value !== undefined && !/^[a-z]{2}$/i.test(value)) throw new GeoError("GEO_UNSUPPORTED_OPTION");
|
|
51
|
+
};
|
|
52
|
+
const area = (value?: GeoPlaceSearchInput["area"]) => {
|
|
53
|
+
if (!value) return;
|
|
54
|
+
if (value.type === "circle") bound("area.radiusMeters", value.radiusMeters, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER);
|
|
55
|
+
else {
|
|
56
|
+
bound("area.west", value.west, -180, 180);
|
|
57
|
+
bound("area.east", value.east, -180, 180);
|
|
58
|
+
bound("area.south", value.south, -90, 90);
|
|
59
|
+
bound("area.north", value.north, -90, 90);
|
|
60
|
+
if (value.west >= value.east || value.south >= value.north) throw new GeoError("GEO_UNSUPPORTED_OPTION");
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
export const createGeoService = (
|
|
64
|
+
options: {
|
|
65
|
+
config?: () => Promise<GeoConfig>;
|
|
66
|
+
now?: () => Date;
|
|
67
|
+
adapter?: GeoapifyAdapter;
|
|
68
|
+
cache?: ReturnType<typeof createGeoCache>;
|
|
69
|
+
bucket?: ReturnType<typeof createGeoBucket>;
|
|
70
|
+
ledger?: ReturnType<typeof createGeoLedger>;
|
|
71
|
+
} = {},
|
|
72
|
+
) => {
|
|
73
|
+
const now = options.now ?? (() => new Date());
|
|
74
|
+
const config = options.config ?? readGeoConfig;
|
|
75
|
+
const adapter = options.adapter ?? new GeoapifyAdapter({ now });
|
|
76
|
+
const cache = options.cache ?? createGeoCache();
|
|
77
|
+
const bucket = options.bucket ?? createGeoBucket();
|
|
78
|
+
const ledger = options.ledger ?? createGeoLedger();
|
|
79
|
+
const budgets = (settings: GeoConfig) => ({
|
|
80
|
+
timezone: settings.timezone,
|
|
81
|
+
monthlyCredits: settings.monthlyCredits,
|
|
82
|
+
personDailyCredits: settings.personDailyCredits,
|
|
83
|
+
});
|
|
84
|
+
const recordTransition = async (event: "monthly_budget_exhausted" | "provider_key_rejected", timezone: string, context: GeoContext) => {
|
|
85
|
+
const identity = geoIdentity(context);
|
|
86
|
+
try {
|
|
87
|
+
await ledger.recordTransition(event, timezone, { ...context, ...identity }, now());
|
|
88
|
+
} catch {
|
|
89
|
+
// The transaction rolls back the dedup row too, allowing the next refusal to record it.
|
|
90
|
+
// Preserve the original recovery details and paid/unknown costs on audit outages.
|
|
91
|
+
console.error("[geo] Audit transition could not be recorded", { event });
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const configured = async (context: GeoContext) => {
|
|
95
|
+
geoIdentity(context);
|
|
96
|
+
const value = await config();
|
|
97
|
+
if (!value.apiKey.trim()) throw new GeoError("GEO_NOT_CONFIGURED");
|
|
98
|
+
return value;
|
|
99
|
+
};
|
|
100
|
+
const paid = async <T>(
|
|
101
|
+
fn: GeoFunction,
|
|
102
|
+
input: unknown,
|
|
103
|
+
context: GeoContext,
|
|
104
|
+
settings: GeoConfig,
|
|
105
|
+
schema: z.ZodType<T>,
|
|
106
|
+
request: (signal: AbortSignal) => Promise<T>,
|
|
107
|
+
limit?: number,
|
|
108
|
+
cacheInput = input,
|
|
109
|
+
empty: (value: T) => boolean = () => false,
|
|
110
|
+
): Promise<T> => {
|
|
111
|
+
const identity = geoIdentity(context);
|
|
112
|
+
const locale = context.locale ?? "en";
|
|
113
|
+
const key = geoCacheKey(fn, cacheInput, locale);
|
|
114
|
+
const hit = await cache.get(key, schema);
|
|
115
|
+
if (hit !== null) return hit;
|
|
116
|
+
const deadline = AbortSignal.timeout(GEO_TIMEOUT_MS[fn]);
|
|
117
|
+
const signal = context.signal ? AbortSignal.any([context.signal, deadline]) : deadline;
|
|
118
|
+
const release = await bucket.acquire(fn, geoPlan(settings).rps);
|
|
119
|
+
let reserved: Awaited<ReturnType<typeof ledger.reserve>> | undefined;
|
|
120
|
+
let settlementAttempted = false;
|
|
121
|
+
const reference = geoReferenceCost({ fn, limit });
|
|
122
|
+
try {
|
|
123
|
+
// A canceled request which has not been sent never reserves provider credit.
|
|
124
|
+
signal.throwIfAborted();
|
|
125
|
+
reserved = await ledger.reserve(
|
|
126
|
+
identity.accessSubject,
|
|
127
|
+
budgets(settings),
|
|
128
|
+
geoReserveCost([{ fn, limit }]),
|
|
129
|
+
fn === "places" ? "places" : "address",
|
|
130
|
+
now(),
|
|
131
|
+
);
|
|
132
|
+
const response = await request(signal);
|
|
133
|
+
settlementAttempted = true;
|
|
134
|
+
await ledger.settle(reserved, reference);
|
|
135
|
+
const value = schema.safeParse(response);
|
|
136
|
+
if (!value.success || Buffer.byteLength(JSON.stringify({ data: response })) > GEO_MAX_JSON_BYTES)
|
|
137
|
+
throw new GeoProviderError("GEO_PROVIDER_FAILED", "known");
|
|
138
|
+
await cache.put(key, fn, value.data, empty(value.data));
|
|
139
|
+
return value.data;
|
|
140
|
+
} catch (cause) {
|
|
141
|
+
let error = cause instanceof GeoError ? cause : new GeoError("GEO_PROVIDER_FAILED");
|
|
142
|
+
if (error.code === "GEO_BUDGET_EXHAUSTED" && error.details.scope === "cloud") {
|
|
143
|
+
await recordTransition("monthly_budget_exhausted", settings.timezone, context);
|
|
144
|
+
}
|
|
145
|
+
if (reserved) {
|
|
146
|
+
const charge = cause instanceof GeoProviderError ? cause.charge : cause instanceof GeoError ? "known" : "unknown";
|
|
147
|
+
let settled = charge === "known" ? reference : 0;
|
|
148
|
+
let unknown = charge === "unknown" ? reserved.cost : 0;
|
|
149
|
+
if (!settlementAttempted) {
|
|
150
|
+
settlementAttempted = true;
|
|
151
|
+
try {
|
|
152
|
+
await ledger.settle(reserved, charge === "none" ? 0 : charge === "unknown" ? reserved.cost : reference);
|
|
153
|
+
} catch {
|
|
154
|
+
settled = 0;
|
|
155
|
+
unknown = reserved.cost;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (error.code === "GEO_PROVIDER_FAILED" || error.code === "GEO_BUSY")
|
|
159
|
+
error = new GeoError(error.code, {
|
|
160
|
+
...error.details,
|
|
161
|
+
costs: { settledCredits: settled / 1000, unknownCredits: unknown / 1000 },
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (cause instanceof GeoProviderError && cause.keyRejected) {
|
|
165
|
+
await recordTransition("provider_key_rejected", settings.timezone, context);
|
|
166
|
+
}
|
|
167
|
+
throw error;
|
|
168
|
+
} finally {
|
|
169
|
+
await release();
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const routes = createGeoRouteService({ configured, adapter, cache, bucket, ledger, now, recordTransition });
|
|
173
|
+
return {
|
|
174
|
+
...routes,
|
|
175
|
+
async autocomplete(input: GeoAutocompleteInput, context: GeoContext) {
|
|
176
|
+
const settings = await configured(context);
|
|
177
|
+
const value = parse(GeoAutocompleteInputSchema, input);
|
|
178
|
+
bound("text", value.text.length, 3, 300);
|
|
179
|
+
bound("text", value.text.trim().length, 3, 300);
|
|
180
|
+
bound("limit", value.limit ?? 5, 1, 5, true);
|
|
181
|
+
country(value.countryCode);
|
|
182
|
+
area(value.area);
|
|
183
|
+
return paid(
|
|
184
|
+
"autocomplete",
|
|
185
|
+
value,
|
|
186
|
+
context,
|
|
187
|
+
settings,
|
|
188
|
+
GeoAutocompleteDataSchema,
|
|
189
|
+
(signal) => adapter.autocomplete(value, settings.apiKey, context.locale ?? "en", signal),
|
|
190
|
+
value.limit,
|
|
191
|
+
{ ...value, limit: value.limit ?? 5 },
|
|
192
|
+
(result) => !result.suggestions.length,
|
|
193
|
+
);
|
|
194
|
+
},
|
|
195
|
+
async geocode(input: GeoGeocodeInput, context: GeoContext) {
|
|
196
|
+
const settings = await configured(context);
|
|
197
|
+
const value = parse(GeoGeocodeInputSchema, input);
|
|
198
|
+
if (value.text !== undefined) {
|
|
199
|
+
bound("text", value.text.length, 1, 300);
|
|
200
|
+
bound("text", value.text.trim().length, 1, 300);
|
|
201
|
+
}
|
|
202
|
+
for (const [name, field] of Object.entries(value.fields ?? {}))
|
|
203
|
+
if (field !== undefined) bound(`fields.${name}`, field.length, 0, 200);
|
|
204
|
+
country(value.countryCode);
|
|
205
|
+
if (!!value.text?.trim() === !!Object.values(value.fields ?? {}).some((v) => v?.trim())) throw new GeoError("GEO_UNSUPPORTED_OPTION");
|
|
206
|
+
return paid("geocode", value, context, settings, GeoGeocodeDataSchema, async (signal) => {
|
|
207
|
+
const result = await adapter.geocode(value, settings.apiKey, context.locale ?? "en", signal);
|
|
208
|
+
if (!result.candidates.length) throw new GeoError("GEO_ADDRESS_NOT_FOUND");
|
|
209
|
+
if (result.candidates.length > 1) throw new GeoError("GEO_ADDRESS_AMBIGUOUS", { candidates: result.candidates });
|
|
210
|
+
return { address: result.candidates[0]!, provenance: result.provenance };
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
async reverse(input: GeoReverseInput, context: GeoContext) {
|
|
214
|
+
const settings = await configured(context);
|
|
215
|
+
const value = parse(GeoReverseInputSchema, input);
|
|
216
|
+
return paid("reverse", value, context, settings, GeoReverseDataSchema, async (signal) => {
|
|
217
|
+
const result = await adapter.reverse(value, settings.apiKey, context.locale ?? "en", signal);
|
|
218
|
+
if (!result.address) throw new GeoError("GEO_ADDRESS_NOT_FOUND");
|
|
219
|
+
return { address: result.address, provenance: result.provenance };
|
|
220
|
+
});
|
|
221
|
+
},
|
|
222
|
+
async searchPlaces(input: GeoPlaceSearchInput, context: GeoContext) {
|
|
223
|
+
const settings = await configured(context);
|
|
224
|
+
const value = parse(GeoPlaceSearchInputSchema, input);
|
|
225
|
+
bound("categories", value.categories.length, 1, 5, true);
|
|
226
|
+
bound("limit", value.limit ?? 20, 1, 20, true);
|
|
227
|
+
area(value.area);
|
|
228
|
+
const along = value.along;
|
|
229
|
+
if (along) {
|
|
230
|
+
bound("along.windowKm", along.toKm - along.fromKm, Number.MIN_VALUE, 200);
|
|
231
|
+
bound("along.fromKm", along.fromKm, 0, Number.MAX_SAFE_INTEGER);
|
|
232
|
+
bound("along.bufferMeters", along.bufferMeters ?? 1000, 100, 5000);
|
|
233
|
+
}
|
|
234
|
+
if ([value.near, value.area, value.along].filter(Boolean).length !== 1) throw new GeoError("GEO_UNSUPPORTED_OPTION");
|
|
235
|
+
const cut = along ? cutGeoWindow(decodePolyline6(along.polyline6), along.fromKm * 1000, along.toKm * 1000) : undefined;
|
|
236
|
+
const simplified = cut ? simplifyGeoLine(cut) : undefined;
|
|
237
|
+
const cacheInput = {
|
|
238
|
+
...value,
|
|
239
|
+
limit: value.limit ?? 20,
|
|
240
|
+
categories: [...new Set(value.categories)],
|
|
241
|
+
...(along ? { along: { ...along, bufferMeters: along.bufferMeters ?? 1000 } } : {}),
|
|
242
|
+
};
|
|
243
|
+
return paid<z.infer<typeof GeoPlaceSearchDataSchema>>(
|
|
244
|
+
"places",
|
|
245
|
+
value,
|
|
246
|
+
context,
|
|
247
|
+
settings,
|
|
248
|
+
GeoPlaceSearchDataSchema,
|
|
249
|
+
async (signal) => {
|
|
250
|
+
// Radial reduction and Douglas–Peucker can each move the line by one tolerance.
|
|
251
|
+
// Widen the provider corridor to cover the original cut; filter locally with the requested buffer.
|
|
252
|
+
const providerInput =
|
|
253
|
+
along && simplified
|
|
254
|
+
? {
|
|
255
|
+
...value,
|
|
256
|
+
// Twenty provider candidates still cost one credit; apply the caller's limit after local filtering.
|
|
257
|
+
limit: 20,
|
|
258
|
+
along: {
|
|
259
|
+
...along,
|
|
260
|
+
bufferMeters: Math.min(100000, Math.ceil((along.bufferMeters ?? 1000) + 2 * simplified.toleranceMeters)),
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
: value;
|
|
264
|
+
const result = await adapter.places(providerInput, settings.apiKey, context.locale ?? "en", signal, simplified?.line);
|
|
265
|
+
if (!cut || !along) return result;
|
|
266
|
+
const places = result.places
|
|
267
|
+
.map((place) => {
|
|
268
|
+
const position = locateOnGeoLine(place.point, cut);
|
|
269
|
+
return { ...place, ...position, atMeters: along.fromKm * 1000 + position.atMeters };
|
|
270
|
+
})
|
|
271
|
+
.filter((place) => place.distanceToRouteMeters <= (along.bufferMeters ?? 1000))
|
|
272
|
+
.sort((a, b) => a.atMeters - b.atMeters);
|
|
273
|
+
return {
|
|
274
|
+
...result,
|
|
275
|
+
places: places.slice(0, value.limit ?? 20),
|
|
276
|
+
simplificationToleranceMeters: simplified!.toleranceMeters,
|
|
277
|
+
truncated: result.places.length === 20 && places.length < (value.limit ?? 20),
|
|
278
|
+
};
|
|
279
|
+
},
|
|
280
|
+
value.limit,
|
|
281
|
+
cacheInput,
|
|
282
|
+
(result) => !result.places.length,
|
|
283
|
+
);
|
|
284
|
+
},
|
|
285
|
+
async availability(context: GeoContext) {
|
|
286
|
+
const { accessSubject } = geoIdentity(context);
|
|
287
|
+
const settings = await config();
|
|
288
|
+
if (!settings.apiKey.trim()) {
|
|
289
|
+
const periods = geoPeriods(now(), settings.timezone);
|
|
290
|
+
return {
|
|
291
|
+
configured: false,
|
|
292
|
+
person: { exhausted: false, resetsAt: periods.person.resetsAt },
|
|
293
|
+
cloud: { exhausted: false, resetsAt: periods.cloud.resetsAt },
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
return { configured: true, ...(await ledger.availability(accessSubject, budgets(settings), now())) };
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
};
|
|
300
|
+
export const coreGeo = createGeoService();
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { GEO_PLAN_IDS } from "../../contracts/geo-admin";
|
|
3
|
+
import { readStates } from "../settings/store";
|
|
4
|
+
|
|
5
|
+
export const GEO_PLANS = {
|
|
6
|
+
free: { dailyCredits: 3000, rps: 5 },
|
|
7
|
+
api10: { dailyCredits: 10000, rps: 12 },
|
|
8
|
+
api25: { dailyCredits: 25000, rps: 15 },
|
|
9
|
+
api50: { dailyCredits: 50000, rps: 20 },
|
|
10
|
+
api100: { dailyCredits: 100000, rps: 25 },
|
|
11
|
+
api250: { dailyCredits: 250000, rps: 30 },
|
|
12
|
+
} as const;
|
|
13
|
+
const ConfigSchema = z.object({
|
|
14
|
+
apiKey: z.string(),
|
|
15
|
+
timezone: z.string(),
|
|
16
|
+
plan: z.enum(GEO_PLAN_IDS),
|
|
17
|
+
customRps: z.number().int().positive(),
|
|
18
|
+
customDailyCredits: z.number().int().positive(),
|
|
19
|
+
monthlyCredits: z.number().int().nonnegative(),
|
|
20
|
+
personDailyCredits: z.number().int().nonnegative(),
|
|
21
|
+
});
|
|
22
|
+
export type GeoConfig = z.infer<typeof ConfigSchema>;
|
|
23
|
+
export const geoPlan = (config: GeoConfig) =>
|
|
24
|
+
config.plan === "custom" ? { rps: config.customRps, dailyCredits: config.customDailyCredits } : GEO_PLANS[config.plan];
|
|
25
|
+
export const readGeoConfig = async (): Promise<GeoConfig> => {
|
|
26
|
+
// Direct encrypted-row reads: configuration checks never touch Valkey or put this key in its settings cache.
|
|
27
|
+
const keys = [
|
|
28
|
+
"geo.api_key",
|
|
29
|
+
"geo.plan",
|
|
30
|
+
"geo.custom_rps",
|
|
31
|
+
"geo.custom_daily_credits",
|
|
32
|
+
"geo.monthly_budget_credits",
|
|
33
|
+
"geo.person_daily_credits",
|
|
34
|
+
"app.timezone",
|
|
35
|
+
];
|
|
36
|
+
const values = await readStates(keys);
|
|
37
|
+
const unreadable = keys.filter((key) => key.startsWith("geo.") && values.get(key)?.stored === "unreadable");
|
|
38
|
+
if (unreadable.length) console.warn("[geo] Unreadable settings; geo is not configured", { keys: unreadable });
|
|
39
|
+
return ConfigSchema.parse({
|
|
40
|
+
// A fallback budget must never admit paid work over an unreadable stored value.
|
|
41
|
+
apiKey: unreadable.length ? "" : values.get("geo.api_key")?.value,
|
|
42
|
+
plan: values.get("geo.plan")?.value,
|
|
43
|
+
customRps: values.get("geo.custom_rps")?.value,
|
|
44
|
+
customDailyCredits: values.get("geo.custom_daily_credits")?.value,
|
|
45
|
+
monthlyCredits: values.get("geo.monthly_budget_credits")?.value,
|
|
46
|
+
personDailyCredits: values.get("geo.person_daily_credits")?.value,
|
|
47
|
+
timezone: values.get("app.timezone")?.value,
|
|
48
|
+
});
|
|
49
|
+
};
|
|
@@ -3,9 +3,12 @@ import { appAudienceRoles, hasAnyAppRole } from "../../_internal/app-roles";
|
|
|
3
3
|
import { getApp, listApps } from "../../_internal/registry";
|
|
4
4
|
import type { AppRegistryEntry } from "../../contracts/registry";
|
|
5
5
|
import type { RequestActor } from "../../contracts/shared";
|
|
6
|
+
import type { RequestAuthority } from "../../server/middleware/auth";
|
|
6
7
|
import { resolveAppPresentations } from "../../shared/app-presentation";
|
|
7
8
|
import { type HelpArticleLink, helpLocaleChain, parseHelpReference } from "../../shared/help";
|
|
8
9
|
import { lexHelpMarkdown, walkHelpTokens } from "../../shared/markdown";
|
|
10
|
+
import { canUseApp } from "../app-access";
|
|
11
|
+
import { discoverableApps } from "../app-discovery";
|
|
9
12
|
import { logger } from "../logging";
|
|
10
13
|
import { HELP_RESOURCE_PAGE_SIZE, type HelpSelection, hasHelpBm25, metadata, queryHelp } from "./store";
|
|
11
14
|
import type { HelpReader } from "./types";
|
|
@@ -30,13 +33,16 @@ export const createHelpReader = (
|
|
|
30
33
|
locale: string,
|
|
31
34
|
viewer: RequestActor | undefined,
|
|
32
35
|
dependencies: {
|
|
36
|
+
authority?: RequestAuthority | null;
|
|
33
37
|
db?: SQL;
|
|
34
38
|
listApps?: () => Promise<AppRegistryEntry[]>;
|
|
35
39
|
getApp?: (appId: string) => Promise<AppRegistryEntry | null>;
|
|
36
40
|
} = {},
|
|
37
41
|
): HelpReader => {
|
|
38
42
|
const db = dependencies.db ?? sql;
|
|
39
|
-
const visible = (app: AppRegistryEntry | null): app is AppRegistryEntry =>
|
|
43
|
+
const visible = (app: AppRegistryEntry | null): app is AppRegistryEntry =>
|
|
44
|
+
app !== null && (app.access !== undefined || canReadAppHelp(app, viewer));
|
|
45
|
+
const visibleApps = async (apps: (AppRegistryEntry | null)[]) => discoverableApps(dependencies.authority ?? null, apps.filter(visible));
|
|
40
46
|
const selectionFor = (apps: AppRegistryEntry[]): HelpSelection[] =>
|
|
41
47
|
apps.flatMap((app) =>
|
|
42
48
|
app.help
|
|
@@ -52,12 +58,12 @@ export const createHelpReader = (
|
|
|
52
58
|
);
|
|
53
59
|
const select = async (appId?: string): Promise<HelpSelection[]> => {
|
|
54
60
|
const apps = appId ? [await (dependencies.getApp ?? getApp)(appId)] : await (dependencies.listApps ?? listApps)();
|
|
55
|
-
return selectionFor(resolveAppPresentations(apps
|
|
61
|
+
return selectionFor(resolveAppPresentations(await visibleApps(apps), locale));
|
|
56
62
|
};
|
|
57
63
|
return {
|
|
58
64
|
async manifest(appId) {
|
|
59
65
|
const app = await (dependencies.getApp ?? getApp)(appId);
|
|
60
|
-
if (!visible(app) || !app.help) return null;
|
|
66
|
+
if (!visible(app) || !app.help || !(await canUseApp(dependencies.authority ?? null, app))) return null;
|
|
61
67
|
const [corpus] = await db`SELECT 1 FROM help.corpora WHERE app_id=${app.id} AND manifest_hash=${app.help.manifestHash}`;
|
|
62
68
|
if (!corpus) return null;
|
|
63
69
|
const rows = await queryHelp(
|
|
@@ -110,10 +116,13 @@ export const createHelpReader = (
|
|
|
110
116
|
const referencedApps = new Set([...references].map((reference) => reference.split("/")[0]));
|
|
111
117
|
const apps = references.size
|
|
112
118
|
? resolveAppPresentations(
|
|
113
|
-
(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
119
|
+
(
|
|
120
|
+
await visibleApps(
|
|
121
|
+
referencedApps.size === 1 && referencedApps.has(input.appId)
|
|
122
|
+
? [await (dependencies.getApp ?? getApp)(input.appId)]
|
|
123
|
+
: await (dependencies.listApps ?? listApps)(),
|
|
124
|
+
)
|
|
125
|
+
).filter((app) => referencedApps.has(app.id)),
|
|
117
126
|
locale,
|
|
118
127
|
)
|
|
119
128
|
: [];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AppRegistryHelpSummary } from "../../contracts/registry";
|
|
2
2
|
import type { RequestActor } from "../../contracts/shared";
|
|
3
|
+
import type { RequestAuthority } from "../../server/middleware/auth";
|
|
3
4
|
import type { HelpAccessLevel, HelpArticleLink, HelpDocumentManifest, HelpKind } from "../../shared/help";
|
|
4
5
|
|
|
5
6
|
export type HelpDocument = {
|
|
@@ -52,4 +53,8 @@ export type HelpReader = {
|
|
|
52
53
|
search(input: HelpSearchInput): Promise<HelpMatch[]>;
|
|
53
54
|
read(input: HelpReadInput): Promise<HelpArticle | null>;
|
|
54
55
|
};
|
|
55
|
-
export type HelpReaderFactory = (
|
|
56
|
+
export type HelpReaderFactory = (
|
|
57
|
+
locale: string,
|
|
58
|
+
viewer: RequestActor | undefined,
|
|
59
|
+
options?: { authority?: RequestAuthority | null },
|
|
60
|
+
) => HelpReader;
|
|
@@ -28,7 +28,25 @@ export type ResolvedInvocationAuthority = {
|
|
|
28
28
|
scopes: string[];
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
export type InvocationPrincipalClaims = Pick<
|
|
32
|
+
CloudInvocationClaims,
|
|
33
|
+
| "sub"
|
|
34
|
+
| "principal_type"
|
|
35
|
+
| "session_kind"
|
|
36
|
+
| "scopes"
|
|
37
|
+
| "credential_id"
|
|
38
|
+
| "exp"
|
|
39
|
+
| "act"
|
|
40
|
+
| "credential_kind"
|
|
41
|
+
| "jti"
|
|
42
|
+
| "request_id"
|
|
43
|
+
| "mandate_id"
|
|
44
|
+
| "mandate_revision"
|
|
45
|
+
| "workload_type"
|
|
46
|
+
| "workload_id"
|
|
47
|
+
>;
|
|
48
|
+
|
|
49
|
+
const provenance = (claims: InvocationPrincipalClaims): NonNullable<RequestActor["delegation"]> => ({
|
|
32
50
|
kind: "invocation",
|
|
33
51
|
callingAppId: claims.act.sub.slice("app:".length),
|
|
34
52
|
credentialKind: claims.credential_kind,
|
|
@@ -58,7 +76,7 @@ const mapServiceAccount = (row: ServiceAccountRow): ServiceAccount => ({
|
|
|
58
76
|
});
|
|
59
77
|
|
|
60
78
|
export const resolveInvocationAuthority = async (
|
|
61
|
-
claims:
|
|
79
|
+
claims: InvocationPrincipalClaims,
|
|
62
80
|
query: typeof sql = sql,
|
|
63
81
|
groupsAdminOverride?: string[],
|
|
64
82
|
): Promise<ResolvedInvocationAuthority | null> => {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import { getApp } from "../../_internal/registry";
|
|
3
|
+
import type { AppMeta } from "../../contracts/app";
|
|
4
|
+
import { AppAccessDeniedError } from "../../server/middleware/app-access";
|
|
5
|
+
import type { RequestAuthority } from "../../server/middleware/auth";
|
|
6
|
+
import { canUseApp } from "../app-access";
|
|
7
|
+
import { recordAppAccessDenied } from "../app-access-metrics";
|
|
8
|
+
import { CLOUD_INVOCATION_TOKEN_TTL_SECONDS } from "./constants";
|
|
9
|
+
import { resolveInvocationAuthority } from "./invocation-actor";
|
|
10
|
+
import { type InvocationAuthority, signInvocationToken } from "./invocation-token";
|
|
11
|
+
import { readIdentityGroupsAdmin } from "./runtime-config";
|
|
12
|
+
|
|
13
|
+
/** Reload the principal while retaining the original credential's scopes and mobile-session restriction. */
|
|
14
|
+
export const resolveIssuanceAuthority = async (
|
|
15
|
+
authority: InvocationAuthority,
|
|
16
|
+
callingAppId: string,
|
|
17
|
+
query?: Parameters<typeof resolveInvocationAuthority>[1],
|
|
18
|
+
groupsAdmin?: readonly string[],
|
|
19
|
+
): Promise<RequestAuthority | null> => {
|
|
20
|
+
const preparedGroups = groupsAdmin ?? (query ? await readIdentityGroupsAdmin(query) : undefined);
|
|
21
|
+
return resolveInvocationAuthority(
|
|
22
|
+
{
|
|
23
|
+
...authority,
|
|
24
|
+
act: { sub: `app:${callingAppId}` },
|
|
25
|
+
jti: crypto.randomUUID(),
|
|
26
|
+
exp: Math.floor(Date.now() / 1_000) + CLOUD_INVOCATION_TOKEN_TTL_SECONDS,
|
|
27
|
+
},
|
|
28
|
+
query,
|
|
29
|
+
preparedGroups ? [...preparedGroups] : undefined,
|
|
30
|
+
);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type RegisteredAppAccessOptions = {
|
|
34
|
+
db?: SQL;
|
|
35
|
+
getApp?: (id: string) => Promise<Pick<AppMeta, "id" | "access"> | null>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const requireRegisteredAppAccess = async (
|
|
39
|
+
authority: RequestAuthority | null,
|
|
40
|
+
appId: string,
|
|
41
|
+
options: RegisteredAppAccessOptions = {},
|
|
42
|
+
): Promise<void> => {
|
|
43
|
+
const app = await (options.getApp ?? getApp)(appId).catch(() => null);
|
|
44
|
+
if (!app || !authority || !(await canUseApp(authority, app, { db: options.db }))) {
|
|
45
|
+
if (app) recordAppAccessDenied(app.id);
|
|
46
|
+
throw new AppAccessDeniedError(app ?? { id: appId });
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** The production broker seam for capability, widget, search, stream, and inter-app issuance. */
|
|
51
|
+
export const issueInvocationToken = async (
|
|
52
|
+
params: Parameters<typeof signInvocationToken>[0],
|
|
53
|
+
dependencies: RegisteredAppAccessOptions & {
|
|
54
|
+
groupsAdmin?: readonly string[];
|
|
55
|
+
resolve?: typeof resolveIssuanceAuthority;
|
|
56
|
+
sign?: typeof signInvocationToken;
|
|
57
|
+
} = {},
|
|
58
|
+
): ReturnType<typeof signInvocationToken> => {
|
|
59
|
+
const authority = await (dependencies.resolve ?? resolveIssuanceAuthority)(
|
|
60
|
+
params.authority,
|
|
61
|
+
params.callingAppId,
|
|
62
|
+
dependencies.db,
|
|
63
|
+
dependencies.groupsAdmin,
|
|
64
|
+
);
|
|
65
|
+
await requireRegisteredAppAccess(authority, params.targetAppId, dependencies);
|
|
66
|
+
return (dependencies.sign ?? signInvocationToken)(params);
|
|
67
|
+
};
|
|
@@ -378,7 +378,7 @@ export const prepareIdentitySigner = async (purpose: SigningKeyPurpose): Promise
|
|
|
378
378
|
*/
|
|
379
379
|
export const withActiveIdentitySigner = async <T>(
|
|
380
380
|
purpose: SigningKeyPurpose,
|
|
381
|
-
callback: (signer: PreparedIdentitySigner & { issuer: string }, db: typeof sql) => Promise<T>,
|
|
381
|
+
callback: (signer: PreparedIdentitySigner & { issuer: string; readonly groupsAdmin?: readonly string[] }, db: typeof sql) => Promise<T>,
|
|
382
382
|
options: { pool?: typeof sql; signal?: AbortSignal; timeoutMs?: number } = {},
|
|
383
383
|
): Promise<T> => {
|
|
384
384
|
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
@@ -404,7 +404,9 @@ export const withActiveIdentitySigner = async <T>(
|
|
|
404
404
|
// holding a caller's transaction: every issuance needs just one connection.
|
|
405
405
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
406
406
|
const signer = await wait("signer refresh", () => prepareIdentitySigner(purpose));
|
|
407
|
-
const { issuer } = await wait("identity settings", getIdentityRuntimeConfig);
|
|
407
|
+
const { issuer, groupsAdmin } = await wait("identity settings", getIdentityRuntimeConfig);
|
|
408
|
+
// Keep this issuance independent of cache expiry while it waits for a connection or key lock.
|
|
409
|
+
const preparedGroupsAdmin = Object.freeze([...groupsAdmin]);
|
|
408
410
|
const run = async (db: typeof sql): Promise<{ active: false } | { active: true; result: T }> => {
|
|
409
411
|
// A caller released at its deadline must not sign once a connection frees up.
|
|
410
412
|
check();
|
|
@@ -422,7 +424,7 @@ export const withActiveIdentitySigner = async <T>(
|
|
|
422
424
|
check();
|
|
423
425
|
if (!active) return { active: false };
|
|
424
426
|
step = "signing";
|
|
425
|
-
const result = await callback({ ...signer, issuer }, db);
|
|
427
|
+
const result = await callback({ ...signer, issuer, groupsAdmin: preparedGroupsAdmin }, db);
|
|
426
428
|
// Roll back what the callback wrote instead of committing a result the caller never gets.
|
|
427
429
|
check();
|
|
428
430
|
return { active: true, result };
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
1
2
|
import { env } from "../../config/env";
|
|
2
3
|
import { publicCloudOrigin } from "../../shared/app-url";
|
|
3
4
|
import { get as getSetting } from "../settings";
|
|
5
|
+
import { decryptValue } from "../settings/crypto";
|
|
6
|
+
import { SETTINGS_MAP, validateSettingValue } from "../settings/defaults";
|
|
4
7
|
import { CLOUD_INVOCATION_JWKS_PATH, CLOUD_OAUTH_JWKS_PATH, CLOUD_SESSION_JWKS_PATH, IDENTITY_REFRESH_TIMEOUT_MS } from "./constants";
|
|
5
8
|
|
|
6
9
|
const RUNTIME_CONFIG_TTL_MS = 60_000;
|
|
@@ -29,6 +32,26 @@ const normalizeGroups = (value: unknown): string[] => {
|
|
|
29
32
|
return groups.length > 0 ? groups : ["admins"];
|
|
30
33
|
};
|
|
31
34
|
|
|
35
|
+
/** Transaction-local admin groups for callers that already hold a connection.
|
|
36
|
+
* Never fill the process/settings cache with an uncommitted setting.
|
|
37
|
+
*/
|
|
38
|
+
export const readIdentityGroupsAdmin = async (db: SQL): Promise<readonly string[]> => {
|
|
39
|
+
const key = "freeipa.groups.admin";
|
|
40
|
+
const def = SETTINGS_MAP.get(key);
|
|
41
|
+
if (!def) throw new Error("Missing identity admin-group setting definition");
|
|
42
|
+
const [row] = await db<{ value: string }[]>`SELECT value FROM settings.entries WHERE key = ${key}`;
|
|
43
|
+
let groups: unknown = def.default;
|
|
44
|
+
if (row) {
|
|
45
|
+
try {
|
|
46
|
+
const validated = validateSettingValue(def, await decryptValue(row.value));
|
|
47
|
+
if (validated.ok) groups = validated.value;
|
|
48
|
+
} catch {
|
|
49
|
+
// Match the settings store's unreadable-row fallback without mutating the row.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return Object.freeze(normalizeGroups(groups));
|
|
53
|
+
};
|
|
54
|
+
|
|
32
55
|
const resolveCoreJwksUrl = (issuer: string, path: string, transportOrigin = env.CLOUD_IDENTITY_JWKS_ORIGIN): URL => {
|
|
33
56
|
if (!transportOrigin) return new URL(path, issuer);
|
|
34
57
|
const origin = new URL(transportOrigin);
|
package/src/services/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// biome-ignore-all assist/source/organizeImports: Preserve grouped service barrel exports.
|
|
2
|
+
export { appAccessAuthority, canUseApp, usableAppIds } from "./app-access";
|
|
2
3
|
export { ipa } from "./ipa";
|
|
3
4
|
export { accounts } from "./accounts";
|
|
4
5
|
export { accountsAppService } from "./accounts";
|
|
@@ -227,6 +228,8 @@ export type { WebVitalsOverview, WebVitalsQuery, WebVitalMetric, WebVitalName }
|
|
|
227
228
|
|
|
228
229
|
export { mail } from "./outgoing-mail";
|
|
229
230
|
export type {
|
|
231
|
+
MailContent,
|
|
232
|
+
MailContentContext,
|
|
230
233
|
MailProfile,
|
|
231
234
|
MailAttachment,
|
|
232
235
|
MailMessage,
|
|
@@ -259,3 +262,9 @@ export {
|
|
|
259
262
|
export { LASTING_ACCESS_SIGN_IN_SECONDS, requireConfirmedBrowserSignIn } from "./session/recent";
|
|
260
263
|
|
|
261
264
|
export { deviceOffersApproval } from "./approvals/device";
|
|
265
|
+
|
|
266
|
+
/** Core-owned geo implementation; applications use geoClient from the server entry. */
|
|
267
|
+
export { coreGeo, createGeoService } from "./geo/service";
|
|
268
|
+
export { migrate as migrateGeo } from "./geo/migrate";
|
|
269
|
+
|
|
270
|
+
export { coreGeoMap, createGeoMapService } from "./geo/map";
|