import type { Persona, PersonaName, SeededRow } from "../types.js"; import type { Asking } from "./oracle.js"; import type { IdentifierEntropy } from "./capability.js"; import type { ResourcePlan } from "./http-oracle.js"; import type { Route } from "./routes.js"; /** * Plant the data through the application's own endpoints. * * The database seeder can invent a row because it can read the catalog and be * told exactly why a row was refused. An application will not show us its * schema — but it will tell us why it refused, in a machine-readable form, and * that turns out to be enough. `src/seed/values.ts` corrects the column * Postgres names; this corrects the field the validation library names, and * neither one guesses. * * Three properties have to survive the move off the database, because every * claim the probe makes rests on them: * * the marker is ours 128 bits from a CSPRNG, never transmitted * before this request. Finding it in a response * is an exact match on something only our * creation request could have put there. * the creator is proven the credential the creation request carried was * confirmed by the application itself to * authenticate that persona — from a `/me` reply * naming them, never from the fact that a request * returned 200. * the resource is reachable the creating persona reads it back and gets the * marker. Without that the resource might not * exist at all, and a later "it is gone" would be * about nothing. * * Any one of them missing and the resource is not planted, is not probed, and * is reported as a gap. Silence beats a guess. */ /** A marker: 128 bits from the platform CSPRNG, in a form a URL survives. */ export declare function mintMarker(): string; export interface PlantedResource { plan: ResourcePlan; rows: SeededRow[]; } /** * One resource this run actually created in the developer's application. * * Recorded with the identifier the application assigned rather than as a count, * because a count cannot be cleaned up and cannot be named. Creating through the * app is a real write with no transaction to roll back: a planting attempt that * had to be retried made a resource on every accepted round, a destructive probe * re-plants what it removed, and every one of those is a row sitting in somebody * else's database when the run ends. */ export interface CreatedResource { resourceId: string; /** Where it was created, e.g. `/api/notes`. */ collection: string; /** The item pattern it can be addressed by, e.g. `/api/notes/:id`. */ item: string; /** The dynamic segment's name in {@link item}. */ param: string; /** The identifier the application assigned, or null when the reply carried none. */ id: string | null; /** The field of the representation that carried it, for reading it back. */ idField: string | null; persona: PersonaName; } /** * What this run established about who a resource belongs to, and how. * * With a schema there is a foreign key and the question is settled by a fact in * the catalog. With no schema the question is open by default, and the whole * difference between a cross-user read that fails a build and one that is * printed and fails nothing turns on which of these came back. * * Ranked, strongest first, and only the top two are ever allowed to establish * anything: * * self-attested the resource, read back, carries a field whose *value* is * the creating account's own identifier — the identifier the * application itself named in its account reply — and the same * field carries the other account's identifier on the resource * that account created. The value match is the evidence; the * field's name is not consulted and never could be, because * `ownerId`, `createdBy` and `lastEditedBy` are the same * string to anybody reading a response. * declared the developer or the agent said so in configuration. Exactly * the standing `overrides` already has on the schema path: a * statement by somebody who knows the application, taken at * face value and recorded as theirs. * behavioural the sibling collection returned only the caller's own * resources. Recorded, never sufficient, and never consulted * in the decision below — a personalised view of public data * has exactly that shape, and so does a feed. * none nothing is claimed and the reads stay observations. */ export interface ResourceOwnership { resourceId: string; /** Whether a cross-user read of this resource may be reported as a finding. */ established: boolean; /** The field that carried the owner's identifier, when one did. */ ownerField: string | null; /** How it was established, or precisely what stopped it. For the report. */ reason: string; /** * Entropy of the identifiers the application assigned these resources, * measured only where it could change an outcome. See `measureIdentifiers`. */ identifier: IdentifierEntropy | null; /** * Corroboration, recorded and never decisive: did the collection return only * the caller's own resources? Null where it was not asked. */ scopedCollection: boolean | null; } export interface PlantOutcome { plans: ResourcePlan[]; rows: SeededRow[]; /** Resources no persona could be planted in, and exactly what stopped it. */ skipped: { resource: string; reason: string; }[]; /** Every resource this run created, so it can be removed or named. */ created: CreatedResource[]; /** What was established about who each planted resource belongs to. */ ownership: ResourceOwnership[]; } export interface PlantOptions { baseUrl: string; timeoutMs?: number; headersFor(as: Asking): Record; /** How many times a creation body may be corrected before we give up. */ rounds?: number; /** * Ownership stated by the developer or by the agent that wrote the code, * keyed by collection path: `{"/api/notes": "ownerId"}`. * * Held to the same bar the statement itself implies and no higher: the named * field has to be in the resource the application handed back, and it has to * hold the creating account's own identifier. A declaration that the run can * see is wrong is not honoured — silently trusting it would let a stale config * entry manufacture findings, which is worse than the gap it was written to * close. */ declaredOwners?: Record; /** * Resources whose identifier entropy has to be measured, by resource id. * * Measuring costs a third creation in somebody's application, so it is done * only where the answer could change what is reported — which is where a * granted read might be demoted to a share link, and that needs a second * id-addressed read endpoint over the same resource to even be possible. */ measureIdentifiers?: Set; } /** * The resources the discovered routes describe. * * A route with a single dynamic segment at the end addresses one resource, and * the path above that segment is its collection — which is where REST has put * creates since before any of these frameworks existed. That is a convention * rather than a fact, so it is *tried* rather than assumed: if the application * does not answer a POST there, the resource is skipped and said to be skipped. */ export declare function planResources(routes: Route[]): ResourcePlan[]; interface Sent { status: number | null; text: string; json: unknown; headers: Headers | null; } declare function send(opts: { baseUrl: string; timeoutMs?: number; }, method: string, path: string, headers: Record, body: unknown): Promise; export interface Converged { ok: true; body: Record; response: Sent; /** The fields the application asked for, in the order it asked. */ corrected: string[]; /** Every resource this attempt created on the way, including the last. */ made: number; } export interface Defeated { ok: false; reason: string; corrected: string[]; made: number; } /** * POST until the application accepts it, correcting only what it names. * * The loop ends in one of three ways, and two of them are honest failures: * * - the application accepts the body. Converged. * - it refuses and names no field we can act on. We stop and report what it * said, because permuting field names until one sticks is exactly the * guessing this engine does not do. * - it keeps naming the same field after we have already corrected it. That * field defeated us and is named in the report — an `amount` that has to be * positive *and* less than the account balance is not something a * validation message can teach us, and pretending otherwise would create * resources whose meaning we cannot account for. */ export declare function converge(opts: PlantOptions, path: string, headers: Record, marker: string, seed?: Record): Promise; /** Plant one resource for each persona, in each resource the routes describe. */ export declare function plantThroughApp(opts: PlantOptions, plans: ResourcePlan[], personas: Record): Promise; /** * Decide, from what the two creations showed, whether a cross-user read of this * resource may be reported as a finding. * * Two conditions, and both are about values rather than names: * * - the same field of each persona's own resource holds *that persona's* own * account identifier. One persona alone would not do it: a field holding * the id of whoever happened to create both would be a constant, and a * field holding a value we could not attribute would be a coincidence. * - those account identifiers carry at least 64 bits between them. This is * the whole reason the rule is not a heuristic. With account ids of `u_1` * and `u_2` a `revision` of 1 and 2, a `sortOrder`, a `version` — any small * integer that happens to track creation order — matches exactly as well as * a real owner column does, and the run has no way to tell them apart. With * 122-bit account identifiers, a field holding one did not get it by * accident: the application put it there. * * Neither condition looks at what the field is called, and that is deliberate. * `ownerId` is a name; a name is what a developer typed, and the same * application will have `lastEditedBy` beside it holding somebody else's id. */ export declare function establishOwnership(input: { plan: ResourcePlan; personas: Record; attesting: Record; /** Fields of the resource that say the application published it. */ flaggedPublic?: string[]; declared: string | null; }): ResourceOwnership; /** * Rebuild a resource a probe destroyed, under whatever identifier the * application assigns the replacement. * * This is the method F1 warned would not survive the move, and it does not * survive it unchanged: there is no way to ask an application to reuse an * identifier it chose itself. What can be done is to make an equivalent * resource — same persona, same body, *same marker*, because the marker is the * oracle and a new one would break every comparison already recorded — and * rewrite the row to point at it. The caller is then obliged to rebuild any URL * naming the row, which is why `ResourceOracle.ensurePresent` says so. */ export declare function replanter(opts: PlantOptions, plans: Map, /** * Where a replacement is recorded, so the cleanup pass can see it. * * A re-plant is a POST like any other, and the resource it makes outlives the * run exactly as the original would have. Counting only the resources planted * at the start would under-report the residue on precisely the applications * where the most of it is left: the leaky ones, where every destructive probe * that lands costs another replacement. */ onCreated?: (resource: CreatedResource) => void): (row: SeededRow, as: Asking) => Promise; export { send as sendForTest };