@pikku/core 0.12.135 → 0.12.137
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/CHANGELOG.md +18 -0
- package/dist/analytics/analytics.types.d.ts +20 -0
- package/dist/analytics/anonymous-analytics-identity.d.ts +2 -0
- package/dist/analytics/anonymous-analytics-identity.js +2 -0
- package/dist/analytics/compose-analytics-identity.d.ts +2 -0
- package/dist/analytics/compose-analytics-identity.js +2 -0
- package/dist/analytics/cookie-analytics-identity.d.ts +2 -0
- package/dist/analytics/cookie-analytics-identity.js +2 -0
- package/dist/analytics/define-analytics-events.d.ts +3 -0
- package/dist/analytics/define-analytics-events.js +2 -0
- package/dist/analytics/fan-out-analytics.d.ts +2 -0
- package/dist/analytics/fan-out-analytics.js +2 -0
- package/dist/analytics/logger-analytics-service.d.ts +2 -0
- package/dist/analytics/logger-analytics-service.js +2 -0
- package/dist/analytics/mint-cookie.d.ts +8 -1
- package/dist/analytics/mint-cookie.js +8 -4
- package/dist/function/function-meta.types.d.ts +5 -0
- package/dist/function/functions.types.d.ts +11 -0
- package/dist/function/functions.types.js +11 -0
- package/dist/middleware/require-origin.d.ts +2 -0
- package/dist/middleware/require-origin.js +2 -0
- package/dist/permissions.d.ts +5 -0
- package/dist/permissions.js +5 -0
- package/dist/services/meta-service.d.ts +11 -0
- package/dist/services/meta-service.js +10 -1
- package/dist/utils.d.ts +7 -0
- package/dist/utils.js +16 -0
- package/dist/wirings/flag/define-feature-flags.d.ts +1 -13
- package/dist/wirings/flag/define-feature-flags.js +1 -13
- package/dist/wirings/http/http-runner.js +1 -3
- package/dist/wirings/persona/persona-app-scopes.js +1 -0
- package/dist/wirings/scope/index.d.ts +1 -1
- package/dist/wirings/scope/scope.types.d.ts +14 -0
- package/dist/wirings/scope/validate-scope-definitions.js +1 -0
- package/dist/wirings/trigger/webhook-source-runner.d.ts +2 -2
- package/dist/wirings/trigger/webhook-source-runner.js +15 -15
- package/dist/wirings/trigger/webhook-source.types.d.ts +5 -9
- package/dist/wirings/workflow/scenario-coverage.d.ts +70 -0
- package/dist/wirings/workflow/scenario-coverage.js +159 -0
- package/package.json +2 -1
- package/src/public-surface.json +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
## 0.12.137
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 0c865d9: Changes and changesets replace milestones. The changes commands run on a local queue when the checkout has no fabric project; `pikku changes next` routes to a changes, upgrade or knowledge agent and merges finished changesets. A changeset that creates or alters a table, or has 6 or more changes, needs a plan at `knowledge/plans/<changeset>.plan.json` (anything else goes to an optional judge at `PIKKU_PLAN_JUDGE_URL`), and `changes done` holds it to that plan. `pikku knowledge gaps` replaces `knowledge next`/`reconcile`, and also reports code a merged plan built that is gone and notes that were deleted.
|
|
6
|
+
- c36b066: Scenario coverage moves into OSS. `@pikku/core/scenario/coverage` adds `readScenarioCoverage`, which reports the lines no scenario reaches, the mutations no scenario drives, and the pages scenarios open. It is exposed through `pikku scenario coverage`, `console:getScenarioCoverage`, and a "Not tested by any scenario" card on the Scenarios page. `LocalMetaService.getRpcMeta` now reads the `.internal.gen.json` file that codegen actually writes.
|
|
7
|
+
- 0963b73: Every scope tree now records where it came from: `origin` on `ScopeDefinitionMeta` is `{ kind: 'app' }` for the app's own `defineScope`, `{ kind: 'generated' }` for a tree the CLI wrote into the project (a `.gen.ts`, or the `app` root derived from personas), and `{ kind: 'addon', package, displayName }` for one an installed addon declared. An addon's build stamps its own package and display name on its trees, and the host keeps that name while recording the package it actually installed. `MetaService.getScopesMeta()` reads the result, and the console's all-meta payload carries it as `scopes`.
|
|
8
|
+
|
|
9
|
+
The console's Scopes page becomes **Permissions** and is grouped by that origin: the app's own permissions first, then what Pikku generated into the project, then one card per addon (Pikku's own before third-party, each alphabetical), each card folding to its header — the app's own starts open, every addon's starts folded, and any search or filter opens them all. Above the cards, two filters narrow the page to what one role is given (or to what no role is given yet) and to one source. A permission group no longer says "Not part of any role yet" above lines that are each given to a role.
|
|
10
|
+
|
|
11
|
+
## 0.12.136
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 149faae: Webhook `receive` steps lose their boilerplate. `pikkuWebhookReceive` (from `#pikku/trigger` or `#pikku/addon/trigger`) declares one: it is public, typed to the raw request with a required `http`, and never registered as an RPC, and the inspector rejects a `receive` declared with any other wrapper. `parseJson` (in `@pikku/core/utils`, generated as `#pikku/utils` and `#pikku/addon/utils`) parses text or bytes and answers a body that is not JSON with a 400. A webhook source whose `method` includes `'head'` answers HEAD probes itself with a 200.
|
|
16
|
+
|
|
17
|
+
`receive` no longer returns `{ respond }`: a handshake returns nothing and writes its answer to `http.response`, like any other HTTP function. The webhook route no longer returns a fetch `Response` either. An HTTP function that returns nothing is now answered with whatever status its response has (200 unless it set another), no longer a forced 204. A webhook receive is also left out of contract versioning, since nothing but its own route calls it.
|
|
18
|
+
|
|
1
19
|
## 0.12.135
|
|
2
20
|
|
|
3
21
|
### Patch Changes
|
|
@@ -8,6 +8,7 @@ import type { CoreUserSession, PikkuWire, PikkuWiringTypes } from '../types/core
|
|
|
8
8
|
* events carry more than a fixed set of props.
|
|
9
9
|
*/
|
|
10
10
|
export type AnalyticsEventBase = {
|
|
11
|
+
/** The event name, as declared in `defineAnalyticsEvents`. */
|
|
11
12
|
name: string;
|
|
12
13
|
} & Record<string, unknown>;
|
|
13
14
|
export interface AnalyticsEventInput {
|
|
@@ -20,8 +21,11 @@ export interface AnalyticsEventInput {
|
|
|
20
21
|
* which is what makes an unauthenticated ingest safe to expose.
|
|
21
22
|
*/
|
|
22
23
|
export interface AnalyticsIdentity {
|
|
24
|
+
/** The session's user id, or null for a visitor with no session. */
|
|
23
25
|
userId: string | null;
|
|
26
|
+
/** The session's organization, when it has one. */
|
|
24
27
|
orgId?: string;
|
|
28
|
+
/** The pikku user the session resolves to; absent without a session. */
|
|
25
29
|
pikkuUserId?: string;
|
|
26
30
|
/**
|
|
27
31
|
* Identifiers a destination keys on that pikku does not mint — GA4's
|
|
@@ -80,14 +84,23 @@ resolved?: Pick<AnalyticsIdentity, 'vendorIds' | 'consent' | 'anonymousId'>) =>
|
|
|
80
84
|
* through, so a sink never has to trust — or re-derive — any of them.
|
|
81
85
|
*/
|
|
82
86
|
export interface AnalyticsRecord {
|
|
87
|
+
/** The event name. */
|
|
83
88
|
name: string;
|
|
89
|
+
/** The validated props the event carried. */
|
|
84
90
|
props?: Record<string, unknown>;
|
|
91
|
+
/** When the server accepted the event, as an ISO timestamp. */
|
|
85
92
|
occurredAt: string;
|
|
93
|
+
/** When a browser says it happened, in epoch milliseconds; only on relayed events. */
|
|
86
94
|
at?: number;
|
|
95
|
+
/** Who the event is attributed to, stamped from the session and cookies. */
|
|
87
96
|
userIdentity: AnalyticsIdentity;
|
|
97
|
+
/** The trace the emitting invocation belongs to. */
|
|
88
98
|
traceId?: string;
|
|
99
|
+
/** The function that recorded the event, when one did. */
|
|
89
100
|
functionId?: string;
|
|
101
|
+
/** The kind of wire the event came in on. */
|
|
90
102
|
wireType?: PikkuWiringTypes;
|
|
103
|
+
/** `client` when relayed from a browser, `server` when a function recorded it. */
|
|
91
104
|
source: 'server' | 'client';
|
|
92
105
|
}
|
|
93
106
|
/**
|
|
@@ -100,6 +113,7 @@ export interface AnalyticsRecord {
|
|
|
100
113
|
* to implement and every caller had to choose between.
|
|
101
114
|
*/
|
|
102
115
|
export interface AnalyticsService {
|
|
116
|
+
/** Delivers one batch of accepted records; a rejection is logged, never surfaced to the caller. */
|
|
103
117
|
write(batch: AnalyticsRecord[]): Promise<void>;
|
|
104
118
|
}
|
|
105
119
|
/**
|
|
@@ -111,7 +125,9 @@ export interface AnalyticsService {
|
|
|
111
125
|
* should look like once it gets there is the sink's, and lives in its mapper.
|
|
112
126
|
*/
|
|
113
127
|
export interface AnalyticsSink {
|
|
128
|
+
/** The destination the filtered records are written to. */
|
|
114
129
|
service: AnalyticsService;
|
|
130
|
+
/** Returns true for the records this destination should get; omit to send everything. */
|
|
115
131
|
accepts?: (record: AnalyticsRecord) => boolean;
|
|
116
132
|
}
|
|
117
133
|
/**
|
|
@@ -119,6 +135,7 @@ export interface AnalyticsSink {
|
|
|
119
135
|
* beacon is free to omit `at`.
|
|
120
136
|
*/
|
|
121
137
|
export interface AnalyticsClientContext {
|
|
138
|
+
/** When the browser says the event happened, in epoch milliseconds. */
|
|
122
139
|
at?: number;
|
|
123
140
|
}
|
|
124
141
|
/**
|
|
@@ -127,8 +144,11 @@ export interface AnalyticsClientContext {
|
|
|
127
144
|
* narrow `Events` in its own `SingletonServices`.
|
|
128
145
|
*/
|
|
129
146
|
export interface AnalyticsLog<Events extends AnalyticsEventBase = AnalyticsEventBase> {
|
|
147
|
+
/** Buffers an event for the invocation; pass `client` when relaying one from a browser. */
|
|
130
148
|
record(event: Events, client?: AnalyticsClientContext): Promise<void>;
|
|
149
|
+
/** Writes what is buffered to the service now, rather than when the invocation ends. */
|
|
131
150
|
flush(): Promise<void>;
|
|
151
|
+
/** Flushes and stops accepting events; called for you when the invocation ends. */
|
|
132
152
|
close(): Promise<void>;
|
|
133
153
|
}
|
|
134
154
|
/**
|
|
@@ -19,5 +19,7 @@ export interface AnonymousAnalyticsIdentityOptions {
|
|
|
19
19
|
* browser script needs it, and a cookie scripts cannot touch is both harder to
|
|
20
20
|
* misuse and not subject to the seven-day cap browsers place on script-set
|
|
21
21
|
* ones. An app that wants a vendor SDK to read it must opt out deliberately.
|
|
22
|
+
*
|
|
23
|
+
* @example snippet: analyticsIdentity
|
|
22
24
|
*/
|
|
23
25
|
export declare const anonymousAnalyticsIdentity: (options?: AnonymousAnalyticsIdentityOptions) => AnalyticsIdentityResolver;
|
|
@@ -14,6 +14,8 @@ const DEFAULT_COOKIE = {
|
|
|
14
14
|
* browser script needs it, and a cookie scripts cannot touch is both harder to
|
|
15
15
|
* misuse and not subject to the seven-day cap browsers place on script-set
|
|
16
16
|
* ones. An app that wants a vendor SDK to read it must opt out deliberately.
|
|
17
|
+
*
|
|
18
|
+
* @example snippet: analyticsIdentity
|
|
17
19
|
*/
|
|
18
20
|
export const anonymousAnalyticsIdentity = (options = {}) => {
|
|
19
21
|
return (wire, resolved) => {
|
|
@@ -10,5 +10,7 @@ import type { AnalyticsIdentityResolver } from './analytics.types.js';
|
|
|
10
10
|
*
|
|
11
11
|
* A later resolver wins a key it sets, so a minter's freshly created id
|
|
12
12
|
* replaces the absent one the cookie reader could not find.
|
|
13
|
+
*
|
|
14
|
+
* @example snippet: analyticsIdentity
|
|
13
15
|
*/
|
|
14
16
|
export declare const composeAnalyticsIdentity: (...resolvers: AnalyticsIdentityResolver[]) => AnalyticsIdentityResolver;
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* A later resolver wins a key it sets, so a minter's freshly created id
|
|
11
11
|
* replaces the absent one the cookie reader could not find.
|
|
12
|
+
*
|
|
13
|
+
* @example snippet: analyticsIdentity
|
|
12
14
|
*/
|
|
13
15
|
export const composeAnalyticsIdentity = (...resolvers) => {
|
|
14
16
|
return (wire, initial) => {
|
|
@@ -20,5 +20,7 @@ export interface CookieAnalyticsIdentityOptions {
|
|
|
20
20
|
* Resolves nothing off an HTTP wire, which is correct: a cron task and a queue
|
|
21
21
|
* worker have no browser behind them, so any vendor id they produced would be
|
|
22
22
|
* invented.
|
|
23
|
+
*
|
|
24
|
+
* @example snippet: analyticsIdentity
|
|
23
25
|
*/
|
|
24
26
|
export declare const cookieAnalyticsIdentity: (options: CookieAnalyticsIdentityOptions) => AnalyticsIdentityResolver;
|
|
@@ -9,6 +9,8 @@ const DENIALS = new Set(['0', 'false', 'denied', 'deny', 'no']);
|
|
|
9
9
|
* Resolves nothing off an HTTP wire, which is correct: a cron task and a queue
|
|
10
10
|
* worker have no browser behind them, so any vendor id they produced would be
|
|
11
11
|
* invented.
|
|
12
|
+
*
|
|
13
|
+
* @example snippet: analyticsIdentity
|
|
12
14
|
*/
|
|
13
15
|
export const cookieAnalyticsIdentity = (options) => {
|
|
14
16
|
return (wire) => {
|
|
@@ -7,6 +7,7 @@ import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
|
7
7
|
* and saying so here fails at the declaration rather than in generated code.
|
|
8
8
|
*/
|
|
9
9
|
export type AnalyticsEventPropsSchema = StandardSchemaV1 & {
|
|
10
|
+
/** The object schema's fields, which the CLI reads to list an event's props. */
|
|
10
11
|
shape: Record<string, unknown>;
|
|
11
12
|
};
|
|
12
13
|
/** Events keyed by name; each value is the schema for that event's props. */
|
|
@@ -18,5 +19,7 @@ export type AnalyticsEventDefinitions = Record<string, AnalyticsEventPropsSchema
|
|
|
18
19
|
* It must stay an exported const — the schema pipeline reads the value by name.
|
|
19
20
|
* It registers nothing at runtime: where events go is an `AnalyticsService` on
|
|
20
21
|
* singleton services.
|
|
22
|
+
*
|
|
23
|
+
* @example snippet: analyticsEvents
|
|
21
24
|
*/
|
|
22
25
|
export declare const defineAnalyticsEvents: <const Events extends AnalyticsEventDefinitions>(events: Events) => Events;
|
|
@@ -5,5 +5,7 @@
|
|
|
5
5
|
* It must stay an exported const — the schema pipeline reads the value by name.
|
|
6
6
|
* It registers nothing at runtime: where events go is an `AnalyticsService` on
|
|
7
7
|
* singleton services.
|
|
8
|
+
*
|
|
9
|
+
* @example snippet: analyticsEvents
|
|
8
10
|
*/
|
|
9
11
|
export const defineAnalyticsEvents = (events) => events;
|
|
@@ -11,5 +11,7 @@ import type { AnalyticsService, AnalyticsSink } from './analytics.types.js';
|
|
|
11
11
|
* is down must not cost the others their events. A destination that throws is
|
|
12
12
|
* reported by the caller's existing flush guard, which already treats analytics
|
|
13
13
|
* as best-effort.
|
|
14
|
+
*
|
|
15
|
+
* @example snippet: shopServices
|
|
14
16
|
*/
|
|
15
17
|
export declare const fanOutAnalytics: (sinks: ReadonlyArray<AnalyticsSink | AnalyticsService>) => AnalyticsService;
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* is down must not cost the others their events. A destination that throws is
|
|
11
11
|
* reported by the caller's existing flush guard, which already treats analytics
|
|
12
12
|
* as best-effort.
|
|
13
|
+
*
|
|
14
|
+
* @example snippet: shopServices
|
|
13
15
|
*/
|
|
14
16
|
export const fanOutAnalytics = (sinks) => {
|
|
15
17
|
const resolved = sinks.map((sink) => 'service' in sink ? sink : { service: sink });
|
|
@@ -9,6 +9,8 @@ import type { AnalyticsRecord, AnalyticsService } from './analytics.types.js';
|
|
|
9
9
|
*/
|
|
10
10
|
export declare class LoggerAnalyticsService implements AnalyticsService {
|
|
11
11
|
private readonly logger;
|
|
12
|
+
/** @param logger Receives each event at `debug`. */
|
|
12
13
|
constructor(logger: Logger);
|
|
14
|
+
/** Logs each record in the batch at `debug`. */
|
|
13
15
|
write(batch: AnalyticsRecord[]): Promise<void>;
|
|
14
16
|
}
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export class LoggerAnalyticsService {
|
|
9
9
|
logger;
|
|
10
|
+
/** @param logger Receives each event at `debug`. */
|
|
10
11
|
constructor(logger) {
|
|
11
12
|
this.logger = logger;
|
|
12
13
|
}
|
|
14
|
+
/** Logs each record in the batch at `debug`. */
|
|
13
15
|
async write(batch) {
|
|
14
16
|
for (const event of batch) {
|
|
15
17
|
this.logger.debug(`analytics: ${event.name}`, {
|
|
@@ -10,6 +10,7 @@ export interface MintCookieOptions {
|
|
|
10
10
|
* is answered has already done the thing the send gate was meant to prevent.
|
|
11
11
|
*/
|
|
12
12
|
requires?: string[];
|
|
13
|
+
/** What the visitor agreed to, as resolved by an earlier resolver; `requires` is checked against it. */
|
|
13
14
|
consent?: Record<string, boolean>;
|
|
14
15
|
/**
|
|
15
16
|
* Replace the cookie already on the device rather than returning it.
|
|
@@ -37,8 +38,14 @@ export interface MintCookieOptions {
|
|
|
37
38
|
* has been sent; a cron task and a queue worker have no browser to store it,
|
|
38
39
|
* and a stream's headers are long gone. Both return undefined rather than
|
|
39
40
|
* pretending.
|
|
41
|
+
*
|
|
42
|
+
* @example snippet: mintVendorCookie
|
|
40
43
|
*/
|
|
41
44
|
export declare const mintCookie: (wire: AnyWire, name: string, options: MintCookieOptions, mint: () => string) => string | undefined;
|
|
42
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* Cryptographically random digits, the shape both vendor formats use.
|
|
47
|
+
*
|
|
48
|
+
* @example snippet: mintVendorCookie
|
|
49
|
+
*/
|
|
43
50
|
export declare const randomDigits: (length: number) => string;
|
|
44
51
|
export {};
|
|
@@ -21,11 +21,11 @@ const permitted = (requires, consent) => {
|
|
|
21
21
|
* has been sent; a cron task and a queue worker have no browser to store it,
|
|
22
22
|
* and a stream's headers are long gone. Both return undefined rather than
|
|
23
23
|
* pretending.
|
|
24
|
+
*
|
|
25
|
+
* @example snippet: mintVendorCookie
|
|
24
26
|
*/
|
|
25
27
|
export const mintCookie = (wire, name, options, mint) => {
|
|
26
|
-
const existing = options.overwrite
|
|
27
|
-
? null
|
|
28
|
-
: wire.http?.request?.cookie(name);
|
|
28
|
+
const existing = options.overwrite ? null : wire.http?.request?.cookie(name);
|
|
29
29
|
if (existing)
|
|
30
30
|
return existing;
|
|
31
31
|
const cache = minted.get(wire) ?? new Map();
|
|
@@ -43,7 +43,11 @@ export const mintCookie = (wire, name, options, mint) => {
|
|
|
43
43
|
minted.set(wire, cache);
|
|
44
44
|
return value;
|
|
45
45
|
};
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Cryptographically random digits, the shape both vendor formats use.
|
|
48
|
+
*
|
|
49
|
+
* @example snippet: mintVendorCookie
|
|
50
|
+
*/
|
|
47
51
|
export const randomDigits = (length) => {
|
|
48
52
|
const bytes = new Uint8Array(length);
|
|
49
53
|
crypto.getRandomValues(bytes);
|
|
@@ -49,6 +49,11 @@ export type FunctionRuntimeMeta = {
|
|
|
49
49
|
* everywhere else, so it is never network-callable.
|
|
50
50
|
*/
|
|
51
51
|
scenarioStep?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* A webhook source's `receive` step, declared with `pikkuWebhookReceive`.
|
|
54
|
+
* Only its source's route runs it, so it is never RPC-callable.
|
|
55
|
+
*/
|
|
56
|
+
webhookReceive?: boolean;
|
|
52
57
|
/**
|
|
53
58
|
* The body of a `pikkuScenario(...)`. Only ever run by `pikku scenario run`,
|
|
54
59
|
* so it is held back from the app bootstrap and from every deployed unit.
|
|
@@ -17,12 +17,23 @@ export type CorePikkuPermissionConfig<In = any, Services extends CoreSecretlessS
|
|
|
17
17
|
};
|
|
18
18
|
export declare const pikkuPermission: <In = any, Services extends CoreSecretlessSingletonServices = SecretlessServices<CoreServices>, Wire extends PickRequired<PikkuWire<In, never, false, any, PikkuRPC, never, never>, 'session'> = PickRequired<PikkuWire<In, never, false, any, PikkuRPC, never, never>, 'session'>>(permission: CorePikkuPermission<In, Services, Wire> | CorePikkuPermissionConfig<In, Services, Wire>) => CorePikkuPermission<In, Services, Wire>;
|
|
19
19
|
export type CorePikkuPermissionFactory<In = any, Services extends CoreSecretlessSingletonServices = SecretlessServices<CoreServices>, Wire extends PikkuWire<In, never, false, any, PikkuRPC, never, never> = PikkuWire<In, never, false, any, PikkuRPC, never, never>> = (input: In) => CorePikkuPermission<any, Services, Wire>;
|
|
20
|
+
/**
|
|
21
|
+
* Declares a permission that takes configuration, so one check serves many
|
|
22
|
+
* call sites: `hasProfileRole({ role: 'support' })`.
|
|
23
|
+
*
|
|
24
|
+
* @example snippet: permissionFactory
|
|
25
|
+
*/
|
|
20
26
|
export declare const pikkuPermissionFactory: <In = any>(factory: CorePikkuPermissionFactory<In>) => CorePikkuPermissionFactory<In>;
|
|
21
27
|
/**
|
|
22
28
|
* Renders a human-readable approval prompt for an AI agent, in place of the
|
|
23
29
|
* raw tool arguments.
|
|
24
30
|
*/
|
|
25
31
|
export type CorePikkuApprovalDescription<In = any, Services extends CoreSecretlessSingletonServices = CoreSecretlessSingletonServices> = (services: Services, data: In) => Promise<string>;
|
|
32
|
+
/**
|
|
33
|
+
* Declares the approval prompt a function shows in place of its raw arguments.
|
|
34
|
+
*
|
|
35
|
+
* @example snippet: approvalDescription
|
|
36
|
+
*/
|
|
26
37
|
export declare const pikkuApprovalDescription: <In = any, Services extends CoreSecretlessSingletonServices = CoreSecretlessSingletonServices>(fn: CorePikkuApprovalDescription<In, Services>) => CorePikkuApprovalDescription<In, Services>;
|
|
27
38
|
export type CorePikkuAuth<Services extends CoreSecretlessSingletonServices = SecretlessServices<CoreServices>, Session extends CoreUserSession = CoreUserSession> = (services: Services, session: Session) => Promise<boolean> | boolean;
|
|
28
39
|
export type CorePikkuAuthConfig<Services extends CoreSecretlessSingletonServices = SecretlessServices<CoreServices>, Session extends CoreUserSession = CoreUserSession> = {
|
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
export const pikkuPermission = (permission) => {
|
|
2
2
|
return typeof permission === 'function' ? permission : permission.func;
|
|
3
3
|
};
|
|
4
|
+
/**
|
|
5
|
+
* Declares a permission that takes configuration, so one check serves many
|
|
6
|
+
* call sites: `hasProfileRole({ role: 'support' })`.
|
|
7
|
+
*
|
|
8
|
+
* @example snippet: permissionFactory
|
|
9
|
+
*/
|
|
4
10
|
export const pikkuPermissionFactory = (factory) => {
|
|
5
11
|
return factory;
|
|
6
12
|
};
|
|
13
|
+
/**
|
|
14
|
+
* Declares the approval prompt a function shows in place of its raw arguments.
|
|
15
|
+
*
|
|
16
|
+
* @example snippet: approvalDescription
|
|
17
|
+
*/
|
|
7
18
|
export const pikkuApprovalDescription = (fn) => {
|
|
8
19
|
return fn;
|
|
9
20
|
};
|
|
@@ -16,6 +16,8 @@ export declare const isAllowedOrigin: (requestOrigin: string | null, hostOrigin:
|
|
|
16
16
|
* before the function body. It stops another site's page from posting to an unauthed
|
|
17
17
|
* route — it is not flood control, because `Origin` is trusted from nobody but a browser.
|
|
18
18
|
* A missing `Origin` is rejected too: a real browser sets one on a cross-origin-capable POST.
|
|
19
|
+
*
|
|
20
|
+
* @example snippet: requireOrigin
|
|
19
21
|
*/
|
|
20
22
|
export declare const requireOrigin: import("./middleware.types.js").CorePikkuMiddlewareFactory<{
|
|
21
23
|
/** Extra allowed origins beyond the request's own host, or a resolver for them. */
|
|
@@ -33,6 +33,8 @@ export const isAllowedOrigin = (requestOrigin, hostOrigin, configuredOrigins) =>
|
|
|
33
33
|
* before the function body. It stops another site's page from posting to an unauthed
|
|
34
34
|
* route — it is not flood control, because `Origin` is trusted from nobody but a browser.
|
|
35
35
|
* A missing `Origin` is rejected too: a real browser sets one on a cross-origin-capable POST.
|
|
36
|
+
*
|
|
37
|
+
* @example snippet: requireOrigin
|
|
36
38
|
*/
|
|
37
39
|
export const requireOrigin = pikkuMiddlewareFactory(({ origins = [] } = {}) => pikkuMiddleware({
|
|
38
40
|
name: 'requireOrigin',
|
package/dist/permissions.d.ts
CHANGED
|
@@ -8,6 +8,11 @@ import type { PikkuRPC } from './wirings/rpc/rpc-types.js';
|
|
|
8
8
|
export type PermissionWire = PikkuWire<any, never, false, any, PikkuRPC, never, never>;
|
|
9
9
|
import type { CorePermissionGroup, CorePikkuPermission } from './function/functions.types.js';
|
|
10
10
|
export declare const clearPermissionsCache: () => void;
|
|
11
|
+
/**
|
|
12
|
+
* Applies permissions to every function, ahead of the per-function ones.
|
|
13
|
+
*
|
|
14
|
+
* @example snippet: globalPermission
|
|
15
|
+
*/
|
|
11
16
|
export declare const addGlobalPermission: (permissions: CorePermissionGroup | CorePikkuPermission[], packageName?: string | null) => CorePermissionGroup | CorePikkuPermission[];
|
|
12
17
|
export declare const runPermissions: ({ funcPermissions, services, wire, data, packageName, label, }: {
|
|
13
18
|
funcPermissions?: CorePermissionGroup | CorePikkuPermission[];
|
package/dist/permissions.js
CHANGED
|
@@ -29,6 +29,11 @@ export const clearPermissionsCache = () => {
|
|
|
29
29
|
delete globalPermissionsCache[key];
|
|
30
30
|
}
|
|
31
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* Applies permissions to every function, ahead of the per-function ones.
|
|
34
|
+
*
|
|
35
|
+
* @example snippet: globalPermission
|
|
36
|
+
*/
|
|
32
37
|
export const addGlobalPermission = (permissions, packageName = null) => {
|
|
33
38
|
const state = pikkuState(packageName, 'permissions', 'global');
|
|
34
39
|
if (Array.isArray(permissions)) {
|
|
@@ -10,6 +10,7 @@ import type { FeaturesMeta } from '../wirings/workflow/scenario.types.js';
|
|
|
10
10
|
import type { ResolvedPersona } from './personas-service.js';
|
|
11
11
|
import type { SystemRoleDefinitionsMeta } from '../wirings/role/role.types.js';
|
|
12
12
|
import type { FeatureFlagDefinitionsMeta } from '../wirings/flag/flag.types.js';
|
|
13
|
+
import type { ScopeDefinitionsMeta } from '../wirings/scope/scope.types.js';
|
|
13
14
|
import type { AnalyticsEventsMeta } from '../analytics/analytics.types.js';
|
|
14
15
|
import type { TriggerMeta, TriggerSourceMeta } from '../wirings/trigger/trigger.types.js';
|
|
15
16
|
import type { SecretDefinitionsMeta } from '../wirings/secret/secret.types.js';
|
|
@@ -156,6 +157,14 @@ export interface MetaService {
|
|
|
156
157
|
* seed granted from rather than a second one.
|
|
157
158
|
*/
|
|
158
159
|
getSystemRolesMeta(): Promise<SystemRoleDefinitionsMeta>;
|
|
160
|
+
/**
|
|
161
|
+
* The scope trees declared with `defineScope`, keyed by root, each carrying
|
|
162
|
+
* where it came from.
|
|
163
|
+
*
|
|
164
|
+
* The scope store holds the grantable ids; this holds what the store does
|
|
165
|
+
* not — a root's display name and which addon, if any, declared it.
|
|
166
|
+
*/
|
|
167
|
+
getScopesMeta(): Promise<ScopeDefinitionsMeta>;
|
|
159
168
|
/**
|
|
160
169
|
* The flags declared with `defineFeatureFlags`, keyed by name.
|
|
161
170
|
*
|
|
@@ -199,6 +208,7 @@ export declare class LocalMetaService implements MetaService {
|
|
|
199
208
|
private workflowMetaCache;
|
|
200
209
|
private personasMetaCache;
|
|
201
210
|
private systemRolesMetaCache;
|
|
211
|
+
private scopesMetaCache;
|
|
202
212
|
private featureFlagsMetaCache;
|
|
203
213
|
private analyticsMetaCache;
|
|
204
214
|
private featuresMetaCache;
|
|
@@ -236,6 +246,7 @@ export declare class LocalMetaService implements MetaService {
|
|
|
236
246
|
getWorkflowMeta(): Promise<WorkflowsMeta>;
|
|
237
247
|
getPersonasMeta(): Promise<Record<string, ResolvedPersona>>;
|
|
238
248
|
getSystemRolesMeta(): Promise<SystemRoleDefinitionsMeta>;
|
|
249
|
+
getScopesMeta(): Promise<ScopeDefinitionsMeta>;
|
|
239
250
|
getFeatureFlagsMeta(): Promise<FeatureFlagDefinitionsMeta>;
|
|
240
251
|
getAnalyticsMeta(): Promise<AnalyticsEventsMeta>;
|
|
241
252
|
getFeaturesMeta(): Promise<FeaturesMeta>;
|
|
@@ -16,6 +16,7 @@ export class LocalMetaService {
|
|
|
16
16
|
workflowMetaCache = null;
|
|
17
17
|
personasMetaCache = null;
|
|
18
18
|
systemRolesMetaCache = null;
|
|
19
|
+
scopesMetaCache = null;
|
|
19
20
|
featureFlagsMetaCache = null;
|
|
20
21
|
analyticsMetaCache = null;
|
|
21
22
|
featuresMetaCache = null;
|
|
@@ -115,6 +116,7 @@ export class LocalMetaService {
|
|
|
115
116
|
this.workflowMetaCache = null;
|
|
116
117
|
this.personasMetaCache = null;
|
|
117
118
|
this.systemRolesMetaCache = null;
|
|
119
|
+
this.scopesMetaCache = null;
|
|
118
120
|
this.featureFlagsMetaCache = null;
|
|
119
121
|
this.analyticsMetaCache = null;
|
|
120
122
|
this.featuresMetaCache = null;
|
|
@@ -216,7 +218,7 @@ export class LocalMetaService {
|
|
|
216
218
|
if (this.rpcMetaCache)
|
|
217
219
|
return this.rpcMetaCache;
|
|
218
220
|
try {
|
|
219
|
-
const content = await this.readFile('rpc/pikku-rpc-wirings-meta.gen.json');
|
|
221
|
+
const content = await this.readFile('rpc/pikku-rpc-wirings-meta.internal.gen.json');
|
|
220
222
|
this.rpcMetaCache = content ? JSON.parse(content) : {};
|
|
221
223
|
return this.rpcMetaCache;
|
|
222
224
|
}
|
|
@@ -278,6 +280,13 @@ export class LocalMetaService {
|
|
|
278
280
|
this.systemRolesMetaCache = content ? JSON.parse(content) : {};
|
|
279
281
|
return this.systemRolesMetaCache;
|
|
280
282
|
}
|
|
283
|
+
async getScopesMeta() {
|
|
284
|
+
if (this.scopesMetaCache)
|
|
285
|
+
return this.scopesMetaCache;
|
|
286
|
+
const content = await this.readFile('scopes/pikku-scopes-meta.gen.json');
|
|
287
|
+
this.scopesMetaCache = content ? JSON.parse(content) : {};
|
|
288
|
+
return this.scopesMetaCache;
|
|
289
|
+
}
|
|
281
290
|
async getFeatureFlagsMeta() {
|
|
282
291
|
if (this.featureFlagsMetaCache)
|
|
283
292
|
return this.featureFlagsMetaCache;
|
package/dist/utils.d.ts
CHANGED
|
@@ -4,6 +4,13 @@ export declare const closeWireServices: (logger: Logger, wireServices: WireServi
|
|
|
4
4
|
export declare const createWeakUID: () => string;
|
|
5
5
|
export declare const isSerializable: (data: any) => boolean;
|
|
6
6
|
export declare const getTagGroups: <T>(tagGroups: Record<string, T>, tag: string) => T[];
|
|
7
|
+
/**
|
|
8
|
+
* `JSON.parse` of text or UTF-8 bytes, refusing anything that is not JSON with
|
|
9
|
+
* a 400 rather than a 500.
|
|
10
|
+
*
|
|
11
|
+
* @example snippet: pikkuWebhookReceive
|
|
12
|
+
*/
|
|
13
|
+
export declare const parseJson: <T = any>(input: string | Uint8Array) => T;
|
|
7
14
|
export declare const freezeDedupe: <T>(arr?: readonly T[] | T[] | undefined) => readonly T[];
|
|
8
15
|
/** Stops addon package services first, then the parent singleton services. */
|
|
9
16
|
export declare const stopSingletonServices: () => Promise<void>;
|
package/dist/utils.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getSingletonServices, getAllPackageStates } from './pikku-state.js';
|
|
2
|
+
import { BadRequestError } from './errors/errors.js';
|
|
2
3
|
export const closeWireServices = async (logger, wireServices) => {
|
|
3
4
|
await Promise.all(Object.values(wireServices).map(async (service) => {
|
|
4
5
|
if (service?.close) {
|
|
@@ -44,6 +45,21 @@ export const getTagGroups = (tagGroups, tag) => {
|
|
|
44
45
|
}
|
|
45
46
|
return results;
|
|
46
47
|
};
|
|
48
|
+
/**
|
|
49
|
+
* `JSON.parse` of text or UTF-8 bytes, refusing anything that is not JSON with
|
|
50
|
+
* a 400 rather than a 500.
|
|
51
|
+
*
|
|
52
|
+
* @example snippet: pikkuWebhookReceive
|
|
53
|
+
*/
|
|
54
|
+
export const parseJson = (input) => {
|
|
55
|
+
const text = typeof input === 'string' ? input : new TextDecoder().decode(input);
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(text);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw new BadRequestError('Body is not valid JSON');
|
|
61
|
+
}
|
|
62
|
+
};
|
|
47
63
|
const EMPTY_ARRAY = Object.freeze([]);
|
|
48
64
|
export const freezeDedupe = (arr) => {
|
|
49
65
|
if (!arr || arr.length === 0)
|
|
@@ -16,18 +16,6 @@ import type { CoreFeatureFlags } from './flag.types.js';
|
|
|
16
16
|
* the same reason: a mid-deploy revocation is not something a code edit should
|
|
17
17
|
* be able to cause.
|
|
18
18
|
*
|
|
19
|
-
* @example
|
|
20
|
-
* ```typescript
|
|
21
|
-
* defineFeatureFlags({
|
|
22
|
-
* takeInBike: {
|
|
23
|
-
* description: 'Book a bike in at the counter',
|
|
24
|
-
* anyOf: ['bikes:intake'],
|
|
25
|
-
* },
|
|
26
|
-
* aiAssistant: {
|
|
27
|
-
* description: 'The assistant panel',
|
|
28
|
-
* // no anyOf — the switch is the whole answer
|
|
29
|
-
* },
|
|
30
|
-
* })
|
|
31
|
-
* ```
|
|
19
|
+
* @example snippet: defineFeatureFlags
|
|
32
20
|
*/
|
|
33
21
|
export declare const defineFeatureFlags: (_config: CoreFeatureFlags) => void;
|
|
@@ -15,18 +15,6 @@
|
|
|
15
15
|
* the same reason: a mid-deploy revocation is not something a code edit should
|
|
16
16
|
* be able to cause.
|
|
17
17
|
*
|
|
18
|
-
* @example
|
|
19
|
-
* ```typescript
|
|
20
|
-
* defineFeatureFlags({
|
|
21
|
-
* takeInBike: {
|
|
22
|
-
* description: 'Book a bike in at the counter',
|
|
23
|
-
* anyOf: ['bikes:intake'],
|
|
24
|
-
* },
|
|
25
|
-
* aiAssistant: {
|
|
26
|
-
* description: 'The assistant panel',
|
|
27
|
-
* // no anyOf — the switch is the whole answer
|
|
28
|
-
* },
|
|
29
|
-
* })
|
|
30
|
-
* ```
|
|
18
|
+
* @example snippet: defineFeatureFlags
|
|
31
19
|
*/
|
|
32
20
|
export const defineFeatureFlags = (_config) => { };
|
|
@@ -215,9 +215,7 @@ const executeRoute = async (services, matchedRoute, http, options) => {
|
|
|
215
215
|
await applyWebResponse(http.response, result);
|
|
216
216
|
}
|
|
217
217
|
else if (result === undefined || result === null) {
|
|
218
|
-
|
|
219
|
-
http?.response?.status(204);
|
|
220
|
-
}
|
|
218
|
+
// Nothing returned: the response keeps the status the function left on it.
|
|
221
219
|
}
|
|
222
220
|
else if (route.returnsJSON === false) {
|
|
223
221
|
http?.response?.arrayBuffer(result);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { defineScope } from './define-scope.js';
|
|
2
2
|
export { flattenScopeDefinitions, validateAndBuildScopeDefinitionsMeta, } from './validate-scope-definitions.js';
|
|
3
|
-
export type { CoreScopes, CoreScopeNode, FlatScope, ScopeDefinitionMeta, ScopeDefinitionsMeta, ScopeDefinitions, ScopeNodeMeta, } from './scope.types.js';
|
|
3
|
+
export type { CoreScopes, CoreScopeNode, FlatScope, ScopeDefinitionMeta, ScopeDefinitionsMeta, ScopeDefinitions, ScopeNodeMeta, ScopeOrigin, } from './scope.types.js';
|
|
4
4
|
export { hasScopes, verifyScopes } from '../../scopes.js';
|
|
@@ -21,12 +21,26 @@ export type ScopeNodeMeta = {
|
|
|
21
21
|
description?: string;
|
|
22
22
|
scopes?: Record<string, ScopeNodeMeta>;
|
|
23
23
|
};
|
|
24
|
+
/**
|
|
25
|
+
* Who put a scope tree into the app: the app's own `defineScope`, a file the
|
|
26
|
+
* CLI generated into the project, or an installed addon.
|
|
27
|
+
*/
|
|
28
|
+
export type ScopeOrigin = {
|
|
29
|
+
kind: 'app';
|
|
30
|
+
} | {
|
|
31
|
+
kind: 'generated';
|
|
32
|
+
} | {
|
|
33
|
+
kind: 'addon';
|
|
34
|
+
package: string;
|
|
35
|
+
displayName?: string;
|
|
36
|
+
};
|
|
24
37
|
export type ScopeDefinitionMeta = {
|
|
25
38
|
name: string;
|
|
26
39
|
displayName?: string;
|
|
27
40
|
description?: string;
|
|
28
41
|
scopes?: Record<string, ScopeNodeMeta>;
|
|
29
42
|
sourceFile?: string;
|
|
43
|
+
origin?: ScopeOrigin;
|
|
30
44
|
};
|
|
31
45
|
export type ScopeDefinitions = ScopeDefinitionMeta[];
|
|
32
46
|
export type ScopeDefinitionsMeta = Record<string, ScopeDefinitionMeta>;
|
|
@@ -14,9 +14,9 @@ export declare const subscribedWebhookEvents: (source: string) => string[];
|
|
|
14
14
|
*/
|
|
15
15
|
export declare const receiveWebhookSourceRequest: (sourceName: string, wire: {
|
|
16
16
|
http?: PikkuHTTP;
|
|
17
|
-
}) => Promise<
|
|
17
|
+
}) => Promise<{
|
|
18
18
|
received: number;
|
|
19
|
-
}>;
|
|
19
|
+
} | void>;
|
|
20
20
|
/** The `pikku-incoming-webhooks` worker: runs the trigger an event was queued for. Throws so the queue retries. */
|
|
21
21
|
export declare const dispatchWebhookSourceJob: (job: WebhookSourceJob) => Promise<void>;
|
|
22
22
|
export type WebhookSourceOutcome = {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getSingletonServices, pikkuState } from '../../pikku-state.js';
|
|
2
2
|
import { addFunction, runPikkuFunc } from '../../function/function-runner.js';
|
|
3
|
+
import { parseJson } from '../../utils.js';
|
|
3
4
|
import { PikkuMissingMetaError, UnauthorizedError, } from '../../errors/errors.js';
|
|
4
5
|
import { timingSafeStringEqual, verifyHmacSignature, verifyPublicKeySignature, } from '../../utils/hmac.js';
|
|
5
6
|
import { webhookSecretCredentialName } from './webhook-source.types.js';
|
|
@@ -37,11 +38,11 @@ const getSourceMeta = (source) => {
|
|
|
37
38
|
}
|
|
38
39
|
return meta;
|
|
39
40
|
};
|
|
40
|
-
const runSourceStep = (singletonServices, source, funcId, data) => runPikkuFunc('trigger', source, funcId, {
|
|
41
|
+
const runSourceStep = (singletonServices, source, funcId, data, wire = {}) => runPikkuFunc('trigger', source, funcId, {
|
|
41
42
|
singletonServices,
|
|
42
43
|
auth: false,
|
|
43
44
|
data: () => data,
|
|
44
|
-
wire
|
|
45
|
+
wire,
|
|
45
46
|
});
|
|
46
47
|
const readRequest = async (http) => {
|
|
47
48
|
const request = http?.request;
|
|
@@ -134,31 +135,30 @@ export const receiveWebhookSourceRequest = async (sourceName, wire) => {
|
|
|
134
135
|
const meta = getSourceMeta(sourceName);
|
|
135
136
|
const store = singletonServices.triggerSourceStore;
|
|
136
137
|
if (store && !(await store.getTriggerSource(sourceName))?.enabled) {
|
|
137
|
-
|
|
138
|
+
wire.http?.response?.status(404);
|
|
139
|
+
return;
|
|
138
140
|
}
|
|
139
141
|
const source = pikkuState(null, 'trigger', 'webhookSources').get(sourceName);
|
|
140
142
|
const request = await readRequest(wire.http);
|
|
143
|
+
// A HEAD is a provider checking the URL is live. It carries no events, so
|
|
144
|
+
// it is answered here rather than in every source's `receive`.
|
|
145
|
+
if (request.method.toLowerCase() === 'head') {
|
|
146
|
+
wire.http?.response?.status(200);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
141
149
|
const verified = await verifyRequest(source, request, singletonServices);
|
|
142
150
|
const result = meta.receive
|
|
143
|
-
? await runSourceStep(singletonServices, sourceName, meta.receive, request)
|
|
151
|
+
? await runSourceStep(singletonServices, sourceName, meta.receive, request, wire)
|
|
144
152
|
: {
|
|
145
153
|
events: [
|
|
146
154
|
{
|
|
147
155
|
name: '',
|
|
148
|
-
data: request.body.length
|
|
149
|
-
? JSON.parse(new TextDecoder().decode(request.body))
|
|
150
|
-
: undefined,
|
|
156
|
+
data: request.body.length ? parseJson(request.body) : undefined,
|
|
151
157
|
},
|
|
152
158
|
],
|
|
153
159
|
};
|
|
154
|
-
if (
|
|
155
|
-
|
|
156
|
-
const text = body === undefined
|
|
157
|
-
? null
|
|
158
|
-
: typeof body === 'string'
|
|
159
|
-
? body
|
|
160
|
-
: JSON.stringify(body);
|
|
161
|
-
return new Response(text, { status, headers });
|
|
160
|
+
if (!result) {
|
|
161
|
+
return;
|
|
162
162
|
}
|
|
163
163
|
if (!verified && result.events.length > 0) {
|
|
164
164
|
throw new UnauthorizedError(`The ${sourceName} webhook source received events in an unsigned request`);
|
|
@@ -20,16 +20,12 @@ export type WebhookRequest = {
|
|
|
20
20
|
url: string;
|
|
21
21
|
query: Record<string, string>;
|
|
22
22
|
};
|
|
23
|
+
/**
|
|
24
|
+
* The events a request carries. A handshake returns nothing and answers
|
|
25
|
+
* through `http.response` instead, which is sent only then.
|
|
26
|
+
*/
|
|
23
27
|
export type WebhookReceiveResult = {
|
|
24
28
|
events: TriggerEvent[];
|
|
25
|
-
}
|
|
26
|
-
/** A handshake, such as Slack's `url_verification`: answered directly, nothing is dispatched. */
|
|
27
|
-
| {
|
|
28
|
-
respond: {
|
|
29
|
-
status: number;
|
|
30
|
-
body?: unknown;
|
|
31
|
-
headers?: Record<string, string>;
|
|
32
|
-
};
|
|
33
29
|
};
|
|
34
30
|
export type WebhookSourceMethod = 'post' | 'put' | 'get' | 'head';
|
|
35
31
|
/** Whatever `setup` wants back on the next deploy, for providers whose endpoints cannot be found by label. */
|
|
@@ -128,7 +124,7 @@ export type CoreTriggerWebhookSource<Events extends Record<string, StandardSchem
|
|
|
128
124
|
*/
|
|
129
125
|
verify?: WebhookVerify;
|
|
130
126
|
/** Omitted: the JSON body is one event dispatched to the trigger named `<name>`. */
|
|
131
|
-
receive?: SourceFunction<WebhookRequest, WebhookReceiveResult>;
|
|
127
|
+
receive?: SourceFunction<WebhookRequest, WebhookReceiveResult | void>;
|
|
132
128
|
check?: SourceFunction<WebhookLifecycleInput, WebhookCheckResult>;
|
|
133
129
|
setup?: SourceFunction<WebhookLifecycleInput, WebhookSetupResult>;
|
|
134
130
|
teardown?: SourceFunction<WebhookTeardownInput, WebhookTeardownResult>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { MetaService } from '../../services/meta-service.js';
|
|
2
|
+
/** Per-function line coverage as `pikku scenario run --coverage` records it. */
|
|
3
|
+
export interface ScenarioFunctionCoverage {
|
|
4
|
+
name: string;
|
|
5
|
+
sourceFile: string;
|
|
6
|
+
status: 'covered' | 'partial' | 'uncovered' | 'unknown';
|
|
7
|
+
totalLines: number;
|
|
8
|
+
missedLines: number[];
|
|
9
|
+
}
|
|
10
|
+
/** The file `pikku scenario run --coverage` writes to `.pikku/coverage/scenario-coverage.json`. */
|
|
11
|
+
export interface ScenarioCoverageFile {
|
|
12
|
+
generatedAt: string;
|
|
13
|
+
environment: string;
|
|
14
|
+
scenarios: Record<string, {
|
|
15
|
+
functions?: ScenarioFunctionCoverage[];
|
|
16
|
+
summary?: {
|
|
17
|
+
total?: number;
|
|
18
|
+
};
|
|
19
|
+
}>;
|
|
20
|
+
}
|
|
21
|
+
/** A function with lines no scenario reaches. */
|
|
22
|
+
export interface ScenarioCoverageGap {
|
|
23
|
+
function: string;
|
|
24
|
+
sourceFile: string;
|
|
25
|
+
status: 'uncovered' | 'partial';
|
|
26
|
+
/** Collapsed line ranges, e.g. `["L14-31", "L42"]`. */
|
|
27
|
+
missing: string[];
|
|
28
|
+
missedLines: number;
|
|
29
|
+
totalLines: number;
|
|
30
|
+
}
|
|
31
|
+
/** A mutation a user can perform that no scenario drives. */
|
|
32
|
+
export interface UncoveredMutation {
|
|
33
|
+
id: string;
|
|
34
|
+
sourceFile?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface ScenarioCoverage {
|
|
37
|
+
/** Line coverage across every scenario; `null` until a run with `--coverage` has been recorded. */
|
|
38
|
+
api: {
|
|
39
|
+
generatedAt: string;
|
|
40
|
+
environment: string;
|
|
41
|
+
pct: number;
|
|
42
|
+
covered: number;
|
|
43
|
+
total: number;
|
|
44
|
+
gaps: ScenarioCoverageGap[];
|
|
45
|
+
} | null;
|
|
46
|
+
mutations: {
|
|
47
|
+
required: number;
|
|
48
|
+
covered: number;
|
|
49
|
+
uncovered: UncoveredMutation[];
|
|
50
|
+
};
|
|
51
|
+
/** Paths a scenario opens; `unvisited` only when the caller knows the app's routes. */
|
|
52
|
+
routes: {
|
|
53
|
+
visited: string[];
|
|
54
|
+
total: number | null;
|
|
55
|
+
unvisited: string[] | null;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Lines no scenario reaches, per function: a line counts as covered if any scenario hit it. */
|
|
59
|
+
export declare function aggregateScenarioCoverageGaps(file: ScenarioCoverageFile): ScenarioCoverageGap[];
|
|
60
|
+
/**
|
|
61
|
+
* Everything a scenario measures about the app, read from the meta codegen and
|
|
62
|
+
* `pikku scenario run --coverage` leave in `.pikku`.
|
|
63
|
+
*
|
|
64
|
+
* A mutation is a function a user changes something with: a mutating HTTP
|
|
65
|
+
* route, or an exposed RPC whose name opens with a mutating verb. Only RPCs a
|
|
66
|
+
* scenario can drive are required.
|
|
67
|
+
*/
|
|
68
|
+
export declare function readScenarioCoverage(metaService: MetaService, { routes }?: {
|
|
69
|
+
routes?: string[];
|
|
70
|
+
}): Promise<ScenarioCoverage>;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete']);
|
|
2
|
+
const MUTATING_VERB = /^(create|add|new|update|edit|set|change|rename|delete|remove|destroy|archive|restore|assign|unassign|submit|approve|reject|cancel|complete|start|stop|send|invite|upload|register|book|reserve|claim|join|leave|mark|toggle|move|reorder|import|apply|save|record|log|generate|grant|revoke|link|unlink|publish|unpublish|enable|disable|accept|decline|resend|retry|sync|trigger|run|provision|deploy|purchase|checkout|pay|refund|subscribe|unsubscribe|reset|confirm|rotate|attach|detach|duplicate|clone|merge|split|schedule|reschedule|dispatch|finish|close|reopen|promote|demote|transfer|swap|seed|clear|cleanup|refresh|rebuild|install|uninstall|connect|disconnect|activate|deactivate|block|unblock|pin|unpin|vote|rate|comment|reply|post)([A-Z0-9]|$)/;
|
|
3
|
+
const NAVIGATION_STEPS = new Set(['opensPage']);
|
|
4
|
+
const isFrameworkTagged = (tags) => (tags ?? []).some((tag) => tag === 'pikku' || tag.startsWith('pikku:'));
|
|
5
|
+
const isGenerated = (sourceFile) => /\.gen\.ts$/.test(sourceFile ?? '');
|
|
6
|
+
const normalisePath = (path) => path.replace(/\/+$/, '') || '/';
|
|
7
|
+
const pct = (covered, total) => total === 0 ? 100 : Math.round((covered / total) * 100);
|
|
8
|
+
function collapseRanges(lines) {
|
|
9
|
+
const sorted = [...new Set(lines)].sort((a, b) => a - b);
|
|
10
|
+
const ranges = [];
|
|
11
|
+
for (let i = 0; i < sorted.length;) {
|
|
12
|
+
let j = i;
|
|
13
|
+
while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1)
|
|
14
|
+
j++;
|
|
15
|
+
ranges.push(sorted[i] === sorted[j] ? `L${sorted[i]}` : `L${sorted[i]}-${sorted[j]}`);
|
|
16
|
+
i = j + 1;
|
|
17
|
+
}
|
|
18
|
+
return ranges;
|
|
19
|
+
}
|
|
20
|
+
/** Lines no scenario reaches, per function: a line counts as covered if any scenario hit it. */
|
|
21
|
+
export function aggregateScenarioCoverageGaps(file) {
|
|
22
|
+
const byFunction = new Map();
|
|
23
|
+
for (const report of Object.values(file.scenarios ?? {})) {
|
|
24
|
+
for (const fn of report.functions ?? []) {
|
|
25
|
+
if (fn.status === 'unknown' || fn.totalLines === 0)
|
|
26
|
+
continue;
|
|
27
|
+
let entry = byFunction.get(fn.name);
|
|
28
|
+
if (!entry) {
|
|
29
|
+
entry = {
|
|
30
|
+
sourceFile: fn.sourceFile,
|
|
31
|
+
missed: null,
|
|
32
|
+
total: fn.totalLines,
|
|
33
|
+
};
|
|
34
|
+
byFunction.set(fn.name, entry);
|
|
35
|
+
}
|
|
36
|
+
const missed = new Set(fn.missedLines);
|
|
37
|
+
entry.missed =
|
|
38
|
+
entry.missed === null
|
|
39
|
+
? missed
|
|
40
|
+
: new Set([...entry.missed].filter((line) => missed.has(line)));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const gaps = [];
|
|
44
|
+
for (const [name, entry] of byFunction) {
|
|
45
|
+
if (!entry.missed || entry.missed.size === 0)
|
|
46
|
+
continue;
|
|
47
|
+
const missed = [...entry.missed];
|
|
48
|
+
gaps.push({
|
|
49
|
+
function: name,
|
|
50
|
+
sourceFile: entry.sourceFile,
|
|
51
|
+
status: missed.length >= entry.total ? 'uncovered' : 'partial',
|
|
52
|
+
missing: collapseRanges(missed),
|
|
53
|
+
missedLines: missed.length,
|
|
54
|
+
totalLines: entry.total,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return gaps.sort((a, b) => (a.status === b.status ? 0 : a.status === 'uncovered' ? -1 : 1) ||
|
|
58
|
+
b.missedLines - a.missedLines ||
|
|
59
|
+
a.function.localeCompare(b.function));
|
|
60
|
+
}
|
|
61
|
+
function scenarioNodes(workflows) {
|
|
62
|
+
return Object.values(workflows).flatMap((meta) => {
|
|
63
|
+
const workflow = meta;
|
|
64
|
+
return workflow?.source === 'scenario'
|
|
65
|
+
? Object.values(workflow.nodes ?? {})
|
|
66
|
+
: [];
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Everything a scenario measures about the app, read from the meta codegen and
|
|
71
|
+
* `pikku scenario run --coverage` leave in `.pikku`.
|
|
72
|
+
*
|
|
73
|
+
* A mutation is a function a user changes something with: a mutating HTTP
|
|
74
|
+
* route, or an exposed RPC whose name opens with a mutating verb. Only RPCs a
|
|
75
|
+
* scenario can drive are required.
|
|
76
|
+
*/
|
|
77
|
+
export async function readScenarioCoverage(metaService, { routes } = {}) {
|
|
78
|
+
const [functions, http, rpc, workflows, coverageFile] = await Promise.all([
|
|
79
|
+
metaService.getFunctionsMeta(),
|
|
80
|
+
metaService.getHttpMeta(),
|
|
81
|
+
metaService.getRpcMeta(),
|
|
82
|
+
metaService.getWorkflowMeta(),
|
|
83
|
+
metaService.readFile('coverage/scenario-coverage.json'),
|
|
84
|
+
]);
|
|
85
|
+
const nodes = scenarioNodes(workflows);
|
|
86
|
+
const driven = new Set(nodes.map((node) => node?.rpcName).filter((name) => !!name));
|
|
87
|
+
const visited = [
|
|
88
|
+
...new Set(nodes
|
|
89
|
+
.filter((node) => node?.rpcName && NAVIGATION_STEPS.has(node.rpcName))
|
|
90
|
+
.map((node) => node.input?.['path'])
|
|
91
|
+
.filter((path) => typeof path === 'string')
|
|
92
|
+
.map(normalisePath)),
|
|
93
|
+
].sort();
|
|
94
|
+
const invocable = new Set(Object.keys(rpc));
|
|
95
|
+
const required = new Map();
|
|
96
|
+
for (const [id, meta] of Object.entries(functions)) {
|
|
97
|
+
const funcId = meta.pikkuFuncId ?? id;
|
|
98
|
+
if (meta.functionType && meta.functionType !== 'user')
|
|
99
|
+
continue;
|
|
100
|
+
if (meta.expose === false || meta.readonly === true)
|
|
101
|
+
continue;
|
|
102
|
+
if (!MUTATING_VERB.test(funcId))
|
|
103
|
+
continue;
|
|
104
|
+
if (isFrameworkTagged(meta.tags) || isGenerated(meta.sourceFile))
|
|
105
|
+
continue;
|
|
106
|
+
if (!invocable.has(funcId))
|
|
107
|
+
continue;
|
|
108
|
+
required.set(funcId, meta.sourceFile);
|
|
109
|
+
}
|
|
110
|
+
for (const [method, wirings] of Object.entries(http)) {
|
|
111
|
+
if (!MUTATING_METHODS.has(method.toLowerCase()))
|
|
112
|
+
continue;
|
|
113
|
+
for (const route of Object.values(wirings ?? {})) {
|
|
114
|
+
const funcId = route?.pikkuFuncId;
|
|
115
|
+
if (!funcId || !invocable.has(funcId))
|
|
116
|
+
continue;
|
|
117
|
+
if (isFrameworkTagged(route.tags))
|
|
118
|
+
continue;
|
|
119
|
+
const sourceFile = functions[funcId]?.sourceFile;
|
|
120
|
+
if (isGenerated(sourceFile))
|
|
121
|
+
continue;
|
|
122
|
+
required.set(funcId, sourceFile);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const uncovered = [...required]
|
|
126
|
+
.filter(([id]) => !driven.has(id))
|
|
127
|
+
.map(([id, sourceFile]) => (sourceFile ? { id, sourceFile } : { id }))
|
|
128
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
129
|
+
let api = null;
|
|
130
|
+
if (coverageFile) {
|
|
131
|
+
const file = JSON.parse(coverageFile);
|
|
132
|
+
const total = Object.values(file.scenarios ?? {}).reduce((most, one) => Math.max(most, one?.summary?.total ?? 0), 0);
|
|
133
|
+
if (total > 0) {
|
|
134
|
+
const gaps = aggregateScenarioCoverageGaps(file);
|
|
135
|
+
api = {
|
|
136
|
+
generatedAt: file.generatedAt,
|
|
137
|
+
environment: file.environment,
|
|
138
|
+
total,
|
|
139
|
+
covered: total - gaps.length,
|
|
140
|
+
pct: pct(total - gaps.length, total),
|
|
141
|
+
gaps,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const known = routes ? [...new Set(routes.map(normalisePath))].sort() : null;
|
|
146
|
+
return {
|
|
147
|
+
api,
|
|
148
|
+
mutations: {
|
|
149
|
+
required: required.size,
|
|
150
|
+
covered: required.size - uncovered.length,
|
|
151
|
+
uncovered,
|
|
152
|
+
},
|
|
153
|
+
routes: {
|
|
154
|
+
visited,
|
|
155
|
+
total: known ? known.length : null,
|
|
156
|
+
unvisited: known ? known.filter((path) => !visited.includes(path)) : null,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/core",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.137",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/pikkujs/pikku.git",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"./channel": "./dist/wirings/channel/index.js",
|
|
40
40
|
"./workflow": "./dist/wirings/workflow/index.js",
|
|
41
41
|
"./scenario": "./dist/wirings/workflow/pikku-scenario-service.js",
|
|
42
|
+
"./scenario/coverage": "./dist/wirings/workflow/scenario-coverage.js",
|
|
42
43
|
"./workflow/timeline": "./dist/wirings/workflow/run-timeline.js",
|
|
43
44
|
"./workflow/types": "./dist/wirings/workflow/workflow.types.js",
|
|
44
45
|
"./actor-flow": "./dist/wirings/actor-flow/index.js",
|
package/src/public-surface.json
CHANGED
|
@@ -99,6 +99,10 @@
|
|
|
99
99
|
"resolveFeatureScenarios",
|
|
100
100
|
"resolveScenarioSurfaces"
|
|
101
101
|
],
|
|
102
|
+
"./scenario/coverage": [
|
|
103
|
+
"aggregateScenarioCoverageGaps",
|
|
104
|
+
"readScenarioCoverage"
|
|
105
|
+
],
|
|
102
106
|
"./workflow/timeline": [
|
|
103
107
|
"buildRunTimeline",
|
|
104
108
|
"reconstructFinalState",
|
|
@@ -572,6 +576,7 @@
|
|
|
572
576
|
"isSerializable",
|
|
573
577
|
"isVersionedId",
|
|
574
578
|
"parseDurationString",
|
|
579
|
+
"parseJson",
|
|
575
580
|
"parseVersionedId",
|
|
576
581
|
"pikkuServerLifecycle",
|
|
577
582
|
"stopSingletonServices"
|