@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,262 @@
|
|
|
1
|
+
import { type SQL, sql } from "bun";
|
|
2
|
+
import { HTTPException } from "hono/http-exception";
|
|
3
|
+
import {
|
|
4
|
+
GEO_USAGE_GROUPS,
|
|
5
|
+
type GeoAdminPerson,
|
|
6
|
+
type GeoAdminStatus,
|
|
7
|
+
type GeoAdminUsage,
|
|
8
|
+
type GeoBudgetField,
|
|
9
|
+
type GeoBudgetFieldError,
|
|
10
|
+
type GeoBudgetInput,
|
|
11
|
+
type GeoKeyError,
|
|
12
|
+
geoKeyProblem,
|
|
13
|
+
validateGeoBudget,
|
|
14
|
+
} from "../../contracts/geo-admin";
|
|
15
|
+
import { audit } from "../audit";
|
|
16
|
+
import { toPgTextArray } from "../postgres";
|
|
17
|
+
import * as settings from "../settings";
|
|
18
|
+
import { decryptValue } from "../settings/crypto";
|
|
19
|
+
import { SETTINGS_MAP, validateSettingValue } from "../settings/defaults";
|
|
20
|
+
import { assertGeoBudgetPair } from "../settings/geo-budget";
|
|
21
|
+
import { GeoProviderError } from "./errors";
|
|
22
|
+
import { GeoapifyAdapter } from "./geoapify";
|
|
23
|
+
import { geoPeriods } from "./ledger";
|
|
24
|
+
import { GEO_PLANS, readGeoConfig } from "./settings";
|
|
25
|
+
|
|
26
|
+
type Actor = { id: string; roles: readonly string[]; uid?: string; provider?: string };
|
|
27
|
+
|
|
28
|
+
const KEY = "geo.api_key";
|
|
29
|
+
const PLAN_KEY = "geo.plan";
|
|
30
|
+
const BUDGET_FIELDS: ReadonlyArray<[GeoBudgetField, string]> = [
|
|
31
|
+
["customRps", "geo.custom_rps"],
|
|
32
|
+
["customDailyCredits", "geo.custom_daily_credits"],
|
|
33
|
+
["monthlyCredits", "geo.monthly_budget_credits"],
|
|
34
|
+
["personDailyCredits", "geo.person_daily_credits"],
|
|
35
|
+
];
|
|
36
|
+
const BUDGET_KEYS = [PLAN_KEY, ...BUDGET_FIELDS.map(([, name]) => name)];
|
|
37
|
+
const KEY_ACTION = "core.geo.key_set";
|
|
38
|
+
const BUDGET_ACTION = "core.geo.budget_changed";
|
|
39
|
+
/** A known place for the verification lookup; any answer that is not a key refusal proves the key works. */
|
|
40
|
+
const PROBE = { point: { lat: 48.3984, lon: 9.9916 } };
|
|
41
|
+
|
|
42
|
+
export class GeoAdminError extends Error {
|
|
43
|
+
constructor(
|
|
44
|
+
readonly body: GeoKeyError | { code: "geo_budget_invalid"; errors: Partial<Record<GeoBudgetField, GeoBudgetFieldError>> },
|
|
45
|
+
readonly status: 400 | 422 | 502,
|
|
46
|
+
) {
|
|
47
|
+
super(body.code); // Never carries the key or a provider body.
|
|
48
|
+
this.name = "GeoAdminError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const requireAdmin = (actor: Actor) => {
|
|
53
|
+
if (!actor.roles.includes("admin")) throw new HTTPException(403, { message: "Administrator access required." });
|
|
54
|
+
};
|
|
55
|
+
const readableSetting = async (key: string, ciphertext: string): Promise<boolean> => {
|
|
56
|
+
const def = SETTINGS_MAP.get(key);
|
|
57
|
+
try {
|
|
58
|
+
return !!def && validateSettingValue(def, await decryptValue(ciphertext)).ok;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const invalidate = async (keys: readonly string[]) => {
|
|
64
|
+
try {
|
|
65
|
+
await settings.invalidateSettingsCache(keys);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error("[geo] committed settings but failed to invalidate the shared cache; TTL recovery remains active", error);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const auditActor = (actor: Actor) => ({ userId: actor.id, uid: actor.uid, provider: actor.provider, roles: actor.roles });
|
|
71
|
+
|
|
72
|
+
type Verify = (apiKey: string, signal?: AbortSignal) => Promise<void>;
|
|
73
|
+
export const verifyGeoapifyKey =
|
|
74
|
+
(adapter = new GeoapifyAdapter()): Verify =>
|
|
75
|
+
async (apiKey, signal) => {
|
|
76
|
+
try {
|
|
77
|
+
await adapter.reverse(PROBE, apiKey, "en", signal);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error instanceof GeoProviderError && error.keyRejected) throw new GeoAdminError({ code: "geo_key_rejected" }, 422);
|
|
80
|
+
// Timeouts, rate limits, malformed answers and unreachable hosts prove nothing about the key.
|
|
81
|
+
throw new GeoAdminError({ code: "geo_key_unverified" }, 502);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const createGeoAdmin = (options: { db?: SQL; verify?: Verify; now?: () => Date } = {}) => {
|
|
86
|
+
const db = options.db ?? sql;
|
|
87
|
+
const verify = options.verify ?? verifyGeoapifyKey();
|
|
88
|
+
const now = options.now ?? (() => new Date());
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The person behind the stored value: the audit event written in the same transaction as the newest of these rows
|
|
92
|
+
* carries that transaction's exact timestamp, and so does every row that event wrote.
|
|
93
|
+
*/
|
|
94
|
+
const changedBy = async (action: string, keys: readonly string[]): Promise<GeoAdminPerson | null> => {
|
|
95
|
+
const [row] = await db<{ actor_user_id: string; name: string | null }[]>`
|
|
96
|
+
SELECT e.actor_user_id, COALESCE(NULLIF(u.display_name, ''), u.uid) AS name
|
|
97
|
+
FROM audit.events e LEFT JOIN auth.users u ON u.id = e.actor_user_id
|
|
98
|
+
WHERE e.action = ${action} AND e.actor_user_id IS NOT NULL
|
|
99
|
+
AND e.created_at = (SELECT max(updated_at) FROM settings.entries WHERE key = ANY(${toPgTextArray([...keys])}::text[]))
|
|
100
|
+
-- Every row this event wrote must still carry its stamp; a later write by another transaction names nobody.
|
|
101
|
+
AND NOT EXISTS (
|
|
102
|
+
SELECT 1 FROM settings.entries s WHERE s.key = ANY(${toPgTextArray([...keys])}::text[])
|
|
103
|
+
AND (e.metadata -> substr(s.key, 5)) IS NOT NULL AND s.updated_at <> e.created_at
|
|
104
|
+
)
|
|
105
|
+
ORDER BY e.id DESC LIMIT 1`;
|
|
106
|
+
return row ? { id: row.actor_user_id, name: row.name ?? row.actor_user_id } : null;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const status = async (actor: Actor): Promise<GeoAdminStatus> => {
|
|
110
|
+
requireAdmin(actor);
|
|
111
|
+
const [config, states, rows, keyBy, budgetBy] = await Promise.all([
|
|
112
|
+
readGeoConfig(),
|
|
113
|
+
settings.readStates([KEY, ...BUDGET_KEYS]),
|
|
114
|
+
db<{ key: string; updated_at: Date }[]>`
|
|
115
|
+
SELECT key, updated_at FROM settings.entries WHERE key = ANY(${toPgTextArray([KEY, ...BUDGET_KEYS])}::text[])`,
|
|
116
|
+
changedBy(KEY_ACTION, [KEY]),
|
|
117
|
+
changedBy(BUDGET_ACTION, BUDGET_KEYS),
|
|
118
|
+
]);
|
|
119
|
+
const updated = new Map(rows.map((row) => [row.key, new Date(row.updated_at)]));
|
|
120
|
+
const key = states.get(KEY);
|
|
121
|
+
const keyState = key?.stored === "unreadable" ? "unreadable" : key?.stored === "readable" && String(key.value).trim() ? "set" : "none";
|
|
122
|
+
const keyAt = keyState === "none" ? null : (updated.get(KEY) ?? null);
|
|
123
|
+
const budgetTimes = BUDGET_KEYS.flatMap((name) => updated.get(name)?.getTime() ?? []);
|
|
124
|
+
const budgetAt = budgetTimes.length ? new Date(Math.max(...budgetTimes)) : null;
|
|
125
|
+
return {
|
|
126
|
+
key: { state: keyState, setAt: keyAt?.toISOString() ?? null, setBy: keyState === "set" ? keyBy : null },
|
|
127
|
+
budget: {
|
|
128
|
+
plan: config.plan,
|
|
129
|
+
customRps: config.customRps,
|
|
130
|
+
customDailyCredits: config.customDailyCredits,
|
|
131
|
+
monthlyCredits: config.monthlyCredits,
|
|
132
|
+
personDailyCredits: config.personDailyCredits,
|
|
133
|
+
unreadable: [["plan", PLAN_KEY] as const, ...BUDGET_FIELDS].flatMap(([field, name]) =>
|
|
134
|
+
states.get(name)?.stored === "unreadable" ? [field] : [],
|
|
135
|
+
),
|
|
136
|
+
changedAt: budgetAt?.toISOString() ?? null,
|
|
137
|
+
changedBy: budgetAt ? budgetBy : null,
|
|
138
|
+
},
|
|
139
|
+
plans: GEO_PLANS,
|
|
140
|
+
timezone: config.timezone,
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const usage = async (actor: Actor): Promise<GeoAdminUsage> => {
|
|
145
|
+
requireAdmin(actor);
|
|
146
|
+
const config = await readGeoConfig();
|
|
147
|
+
const periods = geoPeriods(now(), config.timezone);
|
|
148
|
+
const plan = config.plan === "custom" ? { dailyCredits: config.customDailyCredits } : GEO_PLANS[config.plan];
|
|
149
|
+
const [[month], [today], shares] = await Promise.all([
|
|
150
|
+
db<
|
|
151
|
+
{ used: number }[]
|
|
152
|
+
>`SELECT used::float8 AS used FROM geo.usage WHERE scope = 'cloud' AND subject = '*' AND period = ${periods.cloud.period}`,
|
|
153
|
+
db<{ used: number; at_limit: number }[]>`
|
|
154
|
+
SELECT COALESCE(sum(used), 0)::float8 AS used, count(*) FILTER (WHERE used >= ${config.personDailyCredits * 1000})::int AS at_limit
|
|
155
|
+
FROM geo.usage WHERE scope = 'person' AND period = ${periods.person.period}`,
|
|
156
|
+
db<{ function_group: string; used: number }[]>`
|
|
157
|
+
SELECT function_group, used::float8 AS used FROM geo.shares WHERE scope = 'cloud' AND subject = '*' AND period = ${periods.cloud.period}`,
|
|
158
|
+
]);
|
|
159
|
+
return {
|
|
160
|
+
month: {
|
|
161
|
+
period: periods.cloud.period,
|
|
162
|
+
used: (month?.used ?? 0) / 1000,
|
|
163
|
+
limit: config.monthlyCredits,
|
|
164
|
+
resetsAt: periods.cloud.resetsAt,
|
|
165
|
+
},
|
|
166
|
+
today: {
|
|
167
|
+
period: periods.person.period,
|
|
168
|
+
used: (today?.used ?? 0) / 1000,
|
|
169
|
+
allowance: plan.dailyCredits,
|
|
170
|
+
resetsAt: periods.person.resetsAt,
|
|
171
|
+
},
|
|
172
|
+
shares: GEO_USAGE_GROUPS.map((group) => ({
|
|
173
|
+
group,
|
|
174
|
+
credits: (shares.find((row) => row.function_group === group)?.used ?? 0) / 1000,
|
|
175
|
+
})),
|
|
176
|
+
peopleAtLimit: today?.at_limit ?? 0,
|
|
177
|
+
timezone: config.timezone,
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
status,
|
|
183
|
+
usage,
|
|
184
|
+
/** Store a key only after Geoapify accepted it; until then the previous key stays active. */
|
|
185
|
+
async setKey(actor: Actor, raw: string, signal?: AbortSignal): Promise<GeoAdminStatus> {
|
|
186
|
+
requireAdmin(actor);
|
|
187
|
+
const apiKey = raw.trim();
|
|
188
|
+
const problem = geoKeyProblem(apiKey);
|
|
189
|
+
if (problem) throw new GeoAdminError({ code: problem }, 400);
|
|
190
|
+
await verify(apiKey, signal);
|
|
191
|
+
signal?.throwIfAborted();
|
|
192
|
+
const before = (await settings.readStates([KEY])).get(KEY)?.stored ?? "none";
|
|
193
|
+
await db.begin(async (tx) => {
|
|
194
|
+
// The person replaces whatever is stored, including a value this installation cannot read.
|
|
195
|
+
await settings.set(KEY, apiKey, tx, { replaceUnreadable: true });
|
|
196
|
+
await audit.record(
|
|
197
|
+
{ action: KEY_ACTION, outcome: "allowed", actor: auditActor(actor), metadata: { replaced: before !== "none" } },
|
|
198
|
+
tx,
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
await invalidate([KEY]);
|
|
202
|
+
return status(actor);
|
|
203
|
+
},
|
|
204
|
+
async removeKey(actor: Actor): Promise<GeoAdminStatus> {
|
|
205
|
+
requireAdmin(actor);
|
|
206
|
+
await db.begin(async (tx) => {
|
|
207
|
+
const [row] = await tx<{ key: string }[]>`DELETE FROM settings.entries WHERE key = ${KEY} RETURNING key`;
|
|
208
|
+
if (row) await audit.record({ action: "core.geo.key_removed", outcome: "allowed", actor: auditActor(actor) }, tx);
|
|
209
|
+
});
|
|
210
|
+
await invalidate([KEY]);
|
|
211
|
+
return status(actor);
|
|
212
|
+
},
|
|
213
|
+
async saveBudget(actor: Actor, input: GeoBudgetInput): Promise<GeoAdminStatus> {
|
|
214
|
+
requireAdmin(actor);
|
|
215
|
+
const errors = validateGeoBudget(input);
|
|
216
|
+
if (Object.keys(errors).length) throw new GeoAdminError({ code: "geo_budget_invalid", errors }, 400);
|
|
217
|
+
const updates: Record<string, unknown> = {
|
|
218
|
+
[PLAN_KEY]: input.plan,
|
|
219
|
+
...Object.fromEntries(
|
|
220
|
+
BUDGET_FIELDS.filter(([field]) => input.plan === "custom" || !field.startsWith("custom")).map(([field, name]) => [
|
|
221
|
+
name,
|
|
222
|
+
input[field],
|
|
223
|
+
]),
|
|
224
|
+
),
|
|
225
|
+
};
|
|
226
|
+
// Geo stays off while any geo setting is unreadable. A standard plan hides the custom fields, so saving it
|
|
227
|
+
// resets their unreadable values to the defaults instead of leaving geo off behind a field nobody sees.
|
|
228
|
+
const inactive = BUDGET_FIELDS.flatMap(([, name]) => (name in updates ? [] : [name]));
|
|
229
|
+
const resets: string[] = [];
|
|
230
|
+
await db.begin(async (tx) => {
|
|
231
|
+
// Same lock and final-pair rule as every other writer of these settings.
|
|
232
|
+
await assertGeoBudgetPair(tx, updates, []);
|
|
233
|
+
// Saving the form is the explicit replacement of a value this installation cannot read.
|
|
234
|
+
for (const [name, value] of Object.entries(updates)) await settings.set(name, value, tx, { replaceUnreadable: true });
|
|
235
|
+
// Decided under the row lock: a value another writer repaired meanwhile stays.
|
|
236
|
+
const rows = await tx<{ key: string; value: string }[]>`
|
|
237
|
+
SELECT key, value FROM settings.entries WHERE key = ANY(${toPgTextArray(inactive)}::text[]) FOR UPDATE`;
|
|
238
|
+
for (const row of rows) {
|
|
239
|
+
if (await readableSetting(row.key, row.value)) continue;
|
|
240
|
+
await tx`DELETE FROM settings.entries WHERE key = ${row.key}`;
|
|
241
|
+
resets.push(row.key);
|
|
242
|
+
}
|
|
243
|
+
await audit.record(
|
|
244
|
+
{
|
|
245
|
+
action: BUDGET_ACTION,
|
|
246
|
+
outcome: "allowed",
|
|
247
|
+
actor: auditActor(actor),
|
|
248
|
+
metadata: {
|
|
249
|
+
...Object.fromEntries(Object.entries(updates).map(([name, value]) => [name.slice(4), value])),
|
|
250
|
+
...(resets.length ? { resets: resets.map((name) => name.slice(4)) } : {}),
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
tx,
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
await invalidate([...Object.keys(updates), ...resets]);
|
|
257
|
+
return status(actor);
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
export const geoAdmin = createGeoAdmin();
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { GeoFunction } from "../../contracts/geo";
|
|
3
|
+
import { requestCacheRedis } from "../request-cache-redis";
|
|
4
|
+
import { busy, GeoError } from "./errors";
|
|
5
|
+
|
|
6
|
+
export type GeoRedis = { send(command: string, args: string[]): Promise<unknown> };
|
|
7
|
+
export const GEO_TIMEOUT_MS = { autocomplete: 2500, geocode: 5000, reverse: 5000, places: 8000, route: 15000, tiles: 10000 } as const;
|
|
8
|
+
// One token bucket for all paid APIs; a separate lease set for each function.
|
|
9
|
+
const ACQUIRE = `
|
|
10
|
+
local clock = redis.call('TIME')
|
|
11
|
+
local now = tonumber(clock[1]) * 1000 + tonumber(clock[2]) / 1000
|
|
12
|
+
local rate = tonumber(ARGV[1])
|
|
13
|
+
local leaseMs = tonumber(ARGV[2])
|
|
14
|
+
local cap = tonumber(ARGV[3])
|
|
15
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now)
|
|
16
|
+
if redis.call('ZCARD', KEYS[2]) >= cap then
|
|
17
|
+
local first = redis.call('ZRANGE', KEYS[2], 0, 0, 'WITHSCORES')
|
|
18
|
+
return {0, math.max(1, math.ceil((tonumber(first[2]) - now) / 1000))}
|
|
19
|
+
end
|
|
20
|
+
local old = redis.call('HMGET', KEYS[1], 'tokens', 'at')
|
|
21
|
+
local tokens = math.min(rate, tonumber(old[1]) or rate)
|
|
22
|
+
local at = tonumber(old[2]) or now
|
|
23
|
+
tokens = math.min(rate, tokens + math.max(0, now - at) * rate / 1000)
|
|
24
|
+
if tokens < 1 then return {0, math.max(1, math.ceil((1 - tokens) / rate))} end
|
|
25
|
+
redis.call('HSET', KEYS[1], 'tokens', tokens - 1, 'at', now)
|
|
26
|
+
redis.call('PEXPIRE', KEYS[1], math.ceil(leaseMs + 1000))
|
|
27
|
+
redis.call('ZADD', KEYS[2], now + leaseMs, ARGV[4])
|
|
28
|
+
redis.call('PEXPIRE', KEYS[2], math.ceil(leaseMs + 1000))
|
|
29
|
+
return {1, 0}
|
|
30
|
+
`;
|
|
31
|
+
export const createGeoBucket = (options: { redis?: () => Promise<GeoRedis>; randomId?: () => string; prefix?: string } = {}) => {
|
|
32
|
+
const client = options.redis ?? requestCacheRedis;
|
|
33
|
+
const prefix = options.prefix ?? "geo:{admission}:v1";
|
|
34
|
+
return {
|
|
35
|
+
async acquire(fn: GeoFunction | "tiles", rps: number) {
|
|
36
|
+
try {
|
|
37
|
+
if (!Number.isFinite(rps) || rps < 1) throw busy();
|
|
38
|
+
const id = options.randomId ? options.randomId() : crypto.randomUUID();
|
|
39
|
+
const redis = await client();
|
|
40
|
+
const key = `${prefix}:flight:${fn}`;
|
|
41
|
+
const reply = z
|
|
42
|
+
.tuple([z.number(), z.number()])
|
|
43
|
+
.parse(
|
|
44
|
+
await redis.send("EVAL", [
|
|
45
|
+
ACQUIRE,
|
|
46
|
+
"2",
|
|
47
|
+
`${prefix}:bucket${fn === "tiles" ? ":tiles" : ""}`,
|
|
48
|
+
key,
|
|
49
|
+
String(rps),
|
|
50
|
+
String(GEO_TIMEOUT_MS[fn] + 1000),
|
|
51
|
+
String(Math.ceil((rps * GEO_TIMEOUT_MS[fn]) / 1000)),
|
|
52
|
+
id,
|
|
53
|
+
]),
|
|
54
|
+
);
|
|
55
|
+
if (reply[0] !== 1) throw busy(Math.max(1, reply[1]));
|
|
56
|
+
return async () => {
|
|
57
|
+
await redis.send("ZREM", [key, id]).catch(() => {});
|
|
58
|
+
};
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error instanceof GeoError) throw error;
|
|
61
|
+
// Fail closed, but leave a trace: a script or client defect must not look like ordinary load.
|
|
62
|
+
console.error("[geo] Admission refused after a Valkey failure", { error: error instanceof Error ? error.name : typeof error });
|
|
63
|
+
throw busy();
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { z } from "zod";
|
|
3
|
+
import type { GeoFunction } from "../../contracts/geo";
|
|
4
|
+
import { requestCacheRedis } from "../request-cache-redis";
|
|
5
|
+
import type { GeoRedis } from "./bucket";
|
|
6
|
+
|
|
7
|
+
export const GEO_ADAPTER_VERSION = "geoapify-v2";
|
|
8
|
+
export const GEO_MAX_JSON_BYTES = 256 * 1024;
|
|
9
|
+
// Raw provider geometry is compacted before entering the public result/cache budget.
|
|
10
|
+
export const GEO_MAX_ROUTE_RESPONSE_BYTES = 26 * GEO_MAX_JSON_BYTES;
|
|
11
|
+
const hash = (value: string) => createHash("sha256").update(value).digest("hex");
|
|
12
|
+
export const normalizeGeoInput = (input: unknown, fn: GeoFunction, path = ""): unknown => {
|
|
13
|
+
const field = path.split(".").at(-1);
|
|
14
|
+
if (typeof input === "string" && fn === "route") return field === "polyline6" ? hash(input) : input;
|
|
15
|
+
if (typeof input === "string") return field === "polyline6" ? hash(input) : input.toLowerCase().trim().replace(/\s+/g, " ");
|
|
16
|
+
if (typeof input === "number") {
|
|
17
|
+
if (fn !== "route" && ["lat", "lon", "west", "south", "east", "north"].includes(field ?? ""))
|
|
18
|
+
return Number(input.toFixed(fn === "autocomplete" && (path === "bias.lat" || path === "bias.lon") ? 2 : 5));
|
|
19
|
+
return input;
|
|
20
|
+
}
|
|
21
|
+
if (Array.isArray(input)) {
|
|
22
|
+
const values = input.map((value) => normalizeGeoInput(value, fn, path));
|
|
23
|
+
return field === "categories" || field === "avoid" ? values.sort() : values;
|
|
24
|
+
}
|
|
25
|
+
if (input && typeof input === "object")
|
|
26
|
+
return Object.fromEntries(
|
|
27
|
+
Object.entries(input)
|
|
28
|
+
.filter(([, value]) => value !== undefined)
|
|
29
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
30
|
+
.map(([key, value]) => [key, normalizeGeoInput(value, fn, path ? `${path}.${key}` : key)]),
|
|
31
|
+
);
|
|
32
|
+
return input;
|
|
33
|
+
};
|
|
34
|
+
export const geoCacheKey = (fn: GeoFunction, input: unknown, locale: string) =>
|
|
35
|
+
`geo:cache:v1:${hash(JSON.stringify([GEO_ADAPTER_VERSION, fn, locale, normalizeGeoInput(input, fn)]))}`;
|
|
36
|
+
export const createGeoCache = (client: () => Promise<GeoRedis> = requestCacheRedis) => ({
|
|
37
|
+
async get<T>(key: string, schema: z.ZodType<T>): Promise<T | null> {
|
|
38
|
+
try {
|
|
39
|
+
const value = await (await client()).send("GET", [key]);
|
|
40
|
+
if (typeof value !== "string" || Buffer.byteLength(value) > GEO_MAX_JSON_BYTES) return null;
|
|
41
|
+
const parsed = schema.safeParse(JSON.parse(value));
|
|
42
|
+
return parsed.success ? parsed.data : null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
async put(key: string, fn: GeoFunction, value: unknown, empty: boolean) {
|
|
48
|
+
try {
|
|
49
|
+
const json = JSON.stringify(value);
|
|
50
|
+
if (Buffer.byteLength(json) > GEO_MAX_JSON_BYTES) return;
|
|
51
|
+
const ttl = empty ? 3600 : fn === "autocomplete" ? 7 * 86400 : fn === "places" || fn === "route" ? 86400 : 30 * 86400;
|
|
52
|
+
await (await client()).send("SET", [key, json, "EX", String(ttl)]);
|
|
53
|
+
} catch {
|
|
54
|
+
/* Cache failure does not change an admitted response. */
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { GeoFunction } from "../../contracts/geo";
|
|
2
|
+
export type GeoCostStep = { fn: GeoFunction; limit?: number };
|
|
3
|
+
/** Integer millicredits. Use requested limits, including for empty and short responses. */
|
|
4
|
+
export const geoReferenceCost = ({ fn, limit = 20 }: GeoCostStep): number => (fn === "places" ? Math.ceil(limit / 20) : 1) * 1000;
|
|
5
|
+
export const geoReserveCost = (chain: readonly GeoCostStep[]): number => chain.reduce((sum, step) => sum + geoReferenceCost(step), 0);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { CapabilityErrorStatus } from "../../contracts/capabilities";
|
|
2
|
+
import type { GeoErrorCode } from "../../contracts/geo";
|
|
3
|
+
|
|
4
|
+
export class GeoError extends Error {
|
|
5
|
+
constructor(
|
|
6
|
+
readonly code: GeoErrorCode,
|
|
7
|
+
readonly details: Record<string, unknown> = {},
|
|
8
|
+
) {
|
|
9
|
+
super(code); // Never retain a provider body, request, credential, or transport cause.
|
|
10
|
+
this.name = "GeoError";
|
|
11
|
+
if (code === "GEO_PROVIDER_FAILED") this.details = { costs: { settledCredits: 0, unknownCredits: 0 }, ...details };
|
|
12
|
+
}
|
|
13
|
+
get status(): CapabilityErrorStatus {
|
|
14
|
+
if (this.code === "GEO_FORBIDDEN") return 403;
|
|
15
|
+
if (this.code === "GEO_BUSY") return 429;
|
|
16
|
+
if (this.code === "GEO_PROVIDER_FAILED") return 502;
|
|
17
|
+
if (this.code === "GEO_NOT_CONFIGURED") return 503;
|
|
18
|
+
if (this.code === "GEO_BUDGET_EXHAUSTED") return 429;
|
|
19
|
+
if (this.code === "GEO_ADDRESS_NOT_FOUND") return 404;
|
|
20
|
+
if (this.code === "GEO_ADDRESS_AMBIGUOUS") return 409;
|
|
21
|
+
return 400;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export const limitExceeded = (limit: string, max: number, actual: number): never => {
|
|
25
|
+
throw new GeoError("GEO_LIMIT_EXCEEDED", { limit, max, actual });
|
|
26
|
+
};
|
|
27
|
+
export const busy = (retryAfterSeconds = 1) => new GeoError("GEO_BUSY", { retryAfterSeconds });
|
|
28
|
+
|
|
29
|
+
export class GeoProviderError extends GeoError {
|
|
30
|
+
constructor(
|
|
31
|
+
code: "GEO_PROVIDER_FAILED" | "GEO_BUSY",
|
|
32
|
+
readonly charge: "none" | "known" | "unknown",
|
|
33
|
+
readonly keyRejected = false,
|
|
34
|
+
readonly referenceDistanceMeters?: number,
|
|
35
|
+
) {
|
|
36
|
+
super(code, {
|
|
37
|
+
...(code === "GEO_BUSY" ? { retryAfterSeconds: 1 } : {}),
|
|
38
|
+
...(keyRejected ? { adminHint: "Geoapify rejected the key. Check geo.api_key in Core settings." } : {}),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Geoapify response fixtures
|
|
2
|
+
|
|
3
|
+
These fixtures are adapted from the public Geoapify OpenAPI examples at
|
|
4
|
+
[geoapify/geoapify-openapi-specs](https://github.com/geoapify/geoapify-openapi-specs/tree/dd07a5f).
|
|
5
|
+
They cover autocomplete, forward and reverse geocoding, and Places GeoJSON.
|
|
6
|
+
`along.json` uses the Places response shape; the service test supplies
|
|
7
|
+
synthetic corridor coordinates to check ordering, the window, and distance.
|
|
8
|
+
`routing.json` and `route.test-fixture.ts` supply entirely synthetic stopover
|
|
9
|
+
legs and waypoint permutations in the documented Routing JSON response shape (`format=json`). Optimized waypoint
|
|
10
|
+
order uses `results[0].waypoints` as documented by the provider. Waypoint
|
|
11
|
+
locations may differ from geometry endpoints, as in the published JSON example;
|
|
12
|
+
no input-to-road snap-distance tolerance is assumed. Raw route responses are bounded at 26 ×
|
|
13
|
+
256 KiB, then compacted to the 256 KiB public result budget.
|
|
14
|
+
Opening hours use the documented `datasource.raw.opening_hours` field.
|
|
15
|
+
|
|
16
|
+
Tests use only the placeholder key `test-placeholder-key` and a frozen clock.
|
|
17
|
+
No fixture was obtained through an authenticated provider request.
|