commerce: the 128 silent operations say what they do
`make describe` refuses to regenerate openapi.yaml while any operation is bare,
so the artifact could not be rebuilt AT ALL and had drifted: on clean main the
host served 1698 paths against the golden's 1696 and
cmd/cloud.TestTheServedDocumentIsTheArtifact was red.
128 of the bare operations were commerce's, and commerce registers its routes
from an embedded module, so there is no doc comment here for zipdoc to lift —
openapi.Describe beside the route is the seam. Nine are written out: the wire
top-up rail, the crypto custody rail, the saved-card family, and the tenant's
payment-rail toggle. The other 119 are seventeen merchant kinds behind ONE
generic REST scaffold, so the mechanics are written once and composed with the
kind. Seventeen hand-copied paragraphs describing one generator is the drift
DescribeRest already exists to prevent one level down.
Every sentence is written from the handlers, and what earns space is what a
caller gets wrong:
- PUT is a true REPLACEMENT — the body is decoded onto a FRESH entity, so a
field the body omits is written back as its zero value;
- POST /<kind>/{id} with NO override is a PARTIAL UPDATE, never a create;
- a wallet read renders the account's ENCRYPTED key blob and its salt, so
whoever may read one can attack it offline down to the owner's passphrase —
which is the reason the kind is admin-gated;
- a webhook's delivery consults neither `enabled` nor `live`, so enabled=false
does not stop delivery; deleting the row is what does;
- a discount is enabled by DEFAULT, so a bare create makes a live discount;
- the per-kind permission table covers 5 of the 17 kinds. On the other 12 the
scaffold logs that it is skipping the check and ALLOWS, so the route gate is
the whole authorization story. Each kind now says which it is.
THREE defects were hiding BEHIND that refusal, because describe-apps stops at
the first app that fails and commerce sorts early. Each is repaired, not
recorded:
- integrations: GET /v1/integrations/slack/install was bare. The handler
already carries the prose; it is a raw route, so zipdoc cannot lift it.
- the weave refused two schema names that meant two things. `Role` was iam's
role ENTITY and framework's (user, role) GRANT — the grant is now
RoleAssignment, converted at the handler boundary with the engine type
untouched. `Application` was iam's OAuth client and crm's startup-program
submission — the latter is now ProgramApplication.
- manifest: /v1/event.js, the hosted analytics tag, is served by analytics and
was routed to ai's bare "/v1". A prefix owns SEGMENTS, so "/v1/event" never
covered it, and a browser reads that 404 as a broken script tag rather than
as a routing mistake. Claimed on the analytics row.
All three predate this change and are provable on clean main: regenerating the
stale plugin/iam/openapi.json alone makes the weave fail the same way.
The regenerated zipdoc_gen.go files are the same class of staleness — prose that
was in the Go source and had never reached the artifact.
make describe exits 0. openapi.yaml carries 1735 paths, up from 1696, served
byte-identical to the golden. go build ./... is clean. The pre-existing red
tests (apps/commerce TestBalanceCents/TestInProcessClient, apps/framework's
three, apps/projects TestForkCreatesProjectFromTemplate) fail identically before
and after.
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
@@ -215,6 +215,29 @@ func init() {
|
||||
},
|
||||
Example: json.RawMessage(`{"id":"tgt_1","status":"draining"}`),
|
||||
})
|
||||
zip.Describe("POST /agents/run-on-behalf", zip.Doc{
|
||||
Description: "Answers a bridge's turn.\n\nUnlike the session ops, the org travels IN the request rather than being taken\nfrom the caller's plane identity: the tenant here is the one that connected the\nSlack workspace, resolved by the bridge from the signed team_id, and the bridge\nplugin's own identity is not it. That is safe because this op only SPENDS the\nnamed org's own balance under its own agent — it reads nothing across tenants —\nand because the subject must be a link the bridge already proved.\n\nAn empty subject is refused rather than defaulted. A turn that lost its caller\nmust not run AS THE ORG: that would bill the tenant for an unattributable act\nand hand an unlinked user the org's agent.",
|
||||
Fields: map[string]string{
|
||||
"RunOnBehalfIn.input": "Input is the user's message, already stripped of the leading @mention.",
|
||||
"RunOnBehalfIn.org": "Org is the isolation gate, the tenant, and the balance the run bills.",
|
||||
"RunOnBehalfIn.ref": "Ref names the agent to run.",
|
||||
"RunOnBehalfIn.subject": "Subject is the caller's LINKED Hanzo identity, unqualified. Attribution and\nauthorization both hang off it, so a turn can never run as nobody: the\nanswering side refuses an empty subject rather than falling back to the org.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /agents/sessions/count", zip.Doc{
|
||||
Description: "Answers the active-session count the device view shows,\nunder the same tenancy and actor rules as the stop above.",
|
||||
Fields: map[string]string{
|
||||
"SessionMatchIn.host": "Host/Provider/Account narrow WITHIN the actor's own sessions; empty is any.",
|
||||
"SessionMatchIn.subject": "Subject is the revoking user, unqualified. The answering side qualifies it.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /agents/sessions/stop", zip.Doc{
|
||||
Description: "Tears down every live session of the CALLER's org matching\nthe revoking subject, and reports how many it stopped.\n\nThe org is the caller's plane identity and never the argument — plane\n.SessionMatchIn has no org field, deliberately, because this op STOPS things\nand a caller able to state the org could stop a co-tenant's work. Anonymous is\nrefused rather than defaulted: a teardown arriving with no principal must\nfail, not pick a tenant.\n\nThe actor is built HERE, from the org the plane proved and the subject the\ncaller names, so the HIGH-1 actor scoping (a revoke stops only that user's own\nsessions) is enforced by the side that owns the store rather than trusted from\nthe wire.\n\nA named handler, not a closure, so zipdoc can lift this prose into the registry.",
|
||||
Fields: map[string]string{
|
||||
"SessionMatchIn.host": "Host/Provider/Account narrow WITHIN the actor's own sessions; empty is any.",
|
||||
"SessionMatchIn.subject": "Subject is the revoking user, unqualified. The answering side qualifies it.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/agents", zip.Doc{
|
||||
Description: "Defines an agent in the caller's org: a model, a system prompt\n(instructions) and a set of tool names. The name must be unique in the org and\nmatch ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$. An omitted model takes the\ndeployment's configured default; a named one is checked against the gateway's\nserved catalog, so a model this deployment never serves is refused here rather\nthan failing at run time. A long-running agent must carry a 5-field cron\nschedule (the scheduler would otherwise never fire it) and counts against a\nper-org cap on scheduled agents.",
|
||||
Example: json.RawMessage(`{"name":"helper","model":"enso-flash","instructions":"be terse"}`),
|
||||
|
||||
@@ -32,6 +32,7 @@ func init() {
|
||||
describePlans()
|
||||
describeStore()
|
||||
describeCheckout()
|
||||
describeMerchant()
|
||||
}
|
||||
|
||||
// ---- /_/commerce — the operator surface the ingress withholds publicly ----
|
||||
@@ -55,6 +56,28 @@ func describeAdmin() {
|
||||
"user is refused 403 and an anonymous one 401. A caller whose owner claim has no "+
|
||||
"tenant row gets a 404 byte-identical to the one a cross-tenant probe would get.")
|
||||
|
||||
openapi.Describe("/_/commerce/providers/:name", http.MethodPut,
|
||||
"Turn one payment rail on or off for your own tenant",
|
||||
"Sets the enabled flag on ONE named rail — square, stripe, braintree, plaid, wire or crypto "+
|
||||
"— for the tenant the caller's IAM owner claim resolves to, and answers the tenant name "+
|
||||
"with the same name-and-enabled projection the provider list serves, so a console can "+
|
||||
"render the result without a second read. One rail per call is a correctness requirement "+
|
||||
"rather than a taste: a provider record also carries the KMS path naming where its "+
|
||||
"credentials live, the list read deliberately strips that path, and a PUT that replaced "+
|
||||
"the whole list from what a UI can see would write every rail back with an EMPTY path and "+
|
||||
"silently disconnect each one from its credentials. Naming a single rail copies every "+
|
||||
"other record forward byte for byte. The body must be an explicit enabled true or false — "+
|
||||
"an absent field is 400, never a disable — and a name outside the known set is 400 that "+
|
||||
"lists the names that mean something downstream, because a typo which reports success is "+
|
||||
"worse than one that does not. A tenant admin or a platform admin may call it; anonymous "+
|
||||
"is 401 and a signed-in non-admin 403. No tenant id is accepted from the client, so a "+
|
||||
"cross-tenant write is not expressible, and a caller with no tenant row gets a 404 "+
|
||||
"byte-identical to the one a probe for someone else's tenant would get. Setting a rail to "+
|
||||
"the state it already holds succeeds and changes nothing; a rail the tenant has never "+
|
||||
"carried is appended, which is how one is turned on for the first time — and it is "+
|
||||
"appended with no credential path, so enabling a rail here does not by itself connect it "+
|
||||
"to any credentials.")
|
||||
|
||||
openapi.Describe("/_/commerce/tenants", http.MethodPost,
|
||||
"Create a checkout tenant: hostnames, brand, IAM, IDV, providers and backend",
|
||||
"Registers a new hosted-checkout tenant so its hostnames resolve to their own branding, "+
|
||||
@@ -303,6 +326,116 @@ func describeBilling() {
|
||||
"window, replays the first result, and if that guard store is unreachable the call is "+
|
||||
"refused with 503 rather than risking a second real charge.")
|
||||
|
||||
openapi.Describe("/v1/billing/wire", http.MethodGet,
|
||||
"The bank details and payment reference for topping up by wire",
|
||||
"Answers the receiving bank's name and address, the account name, the account and routing "+
|
||||
"numbers, SWIFT and IBAN, and the reference to put on the transfer. The bank is the "+
|
||||
"SERVING BRAND's, resolved from the request host rather than from the caller's org, and "+
|
||||
"hydrated from that brand's own KMS wire secrets — a deployment holding neither an "+
|
||||
"account number nor an IBAN answers 503 rather than a half-filled form a customer would "+
|
||||
"wire real money against. The reference is derived from the CALLER's own billing key "+
|
||||
"(hanzo/z renders as TOPUP-HANZO-Z), and it is what attributes an incoming wire to an "+
|
||||
"account; memo carries the same value because banks name that field differently. It is an "+
|
||||
"identifier, not a secret — anyone knowing an org slug can derive it, which is safe "+
|
||||
"precisely because reading it moves nothing. NOTHING MINTS HERE: a wire is credited by "+
|
||||
"the admin credit verb when the bank receipt lands, so a caller who has read these "+
|
||||
"instructions has not funded anything yet.")
|
||||
|
||||
openapi.Describe("/v1/billing/crypto/options", http.MethodGet,
|
||||
"The chains and tokens the crypto custody rail will accept",
|
||||
"Answers the chains and the tokens a crypto deposit may be opened against — the list a "+
|
||||
"payment page renders its asset picker from. Both come from the MPC custody processor "+
|
||||
"this deployment is wired to, and the call is gated on that processor answering a health "+
|
||||
"probe within about three seconds: an unreachable or unconfigured custody service is 503 "+
|
||||
"with no cached fallback, because offering a chain that cannot then mint an address is "+
|
||||
"how a payer's funds get stranded. That 503 does not distinguish never-configured from "+
|
||||
"momentarily-unreachable, so treat it as retry-then-choose-another-rail rather than as a "+
|
||||
"permanent answer. The chain list is the processor's and is wider than the deposit "+
|
||||
"intent's own vocabulary, so a chain named here may still be recorded as an unrecognized "+
|
||||
"value carrying default confirmation requirements.")
|
||||
|
||||
openapi.Describe("/v1/billing/crypto/deposit", http.MethodPost,
|
||||
"Open a crypto deposit and get the address to send funds to",
|
||||
"Mints a per-payer custody deposit address on the MPC signer fleet and answers the intent id, "+
|
||||
"its status, the chain and token, the deposit address and when it expires — 200 on both "+
|
||||
"the freshly-minted and the reused path, never 201. The body is optional and defaults to "+
|
||||
"ethereum and usdc. The credited payer is the CALLER's own billing subject and there is "+
|
||||
"deliberately no subject field, so a deposit cannot be opened on anyone else's behalf. A "+
|
||||
"refresh does not spray keygens: an unexpired pending intent for the same payer, chain "+
|
||||
"and token is returned as it stands — with its ORIGINAL expiry, not a refreshed one. The "+
|
||||
"amount is a caller-supplied hint stored verbatim; it binds nothing and is not what "+
|
||||
"credits. NOTHING MINTS HERE — the chain watcher credits the balance on real "+
|
||||
"confirmations, so an opened deposit is worth nothing until the chain says otherwise. An "+
|
||||
"unsupported chain is 400, and a custody service that will not issue an address answers "+
|
||||
"503 rather than 502 on purpose, so the refusal reaches the caller as JSON instead of as "+
|
||||
"an edge's HTML interstitial.")
|
||||
|
||||
openapi.Describe("/v1/billing/crypto/deposit/:id", http.MethodGet,
|
||||
"Poll one crypto deposit until it settles",
|
||||
"Returns the addressed deposit intent — status, chain, token, address and expiry — which is "+
|
||||
"what a checkout page polls after showing the payer an address. The status is one of "+
|
||||
"pending, confirming, succeeded, expired, failed or refunded. The intent is resolved "+
|
||||
"inside the caller org's own namespace AND checked against the caller's own payer key, so "+
|
||||
"another payer's id answers 404 exactly as a missing one does and no 403 exists to tell "+
|
||||
"the two apart. Two things a poller gets wrong: the status does NOT expire itself, so an "+
|
||||
"intent past its expiry still reads pending until something else marks it, and "+
|
||||
"confirmation progress is not projected here — the response carries the state, not the "+
|
||||
"confirmation count or the transaction hash.")
|
||||
|
||||
openapi.Describe("/v1/billing/methods", http.MethodGet,
|
||||
"List the payment methods saved on your account, masked",
|
||||
"Answers the caller's saved payment methods newest first, each masked to what is safe to "+
|
||||
"show: brand, last four, expiry and the processor's reusable reference. No card number "+
|
||||
"and no security code exist on the model to return — they are unrepresentable here rather "+
|
||||
"than filtered out, because the PAN lives at the processor and never enters this system. "+
|
||||
"This is the CUSTOMER face of the rows the service-token portal list serves, at its own "+
|
||||
"address because the app publishing this one cannot forward to the other without "+
|
||||
"re-entering its own handler. The subject filter is pinned to the VALIDATED caller before "+
|
||||
"the handler runs, so a customerId in the query is silently ignored for an ordinary "+
|
||||
"caller rather than refused — you always read your own. A caller with no resolvable "+
|
||||
"subject gets an empty array, never another tenant's cards.")
|
||||
|
||||
openapi.Describe("/v1/billing/methods", http.MethodPost,
|
||||
"Save a card on your account for later charges",
|
||||
"Vaults a tokenized card as a reusable card-on-file at the processor and stores its masked "+
|
||||
"descriptor locally, answering the saved method at 201 — this is the card a monthly "+
|
||||
"renewal or an auto-recharge charges later. The PAN never reaches this service: the "+
|
||||
"browser tokenizes the card and only the single-use nonce arrives. A body typed as a card "+
|
||||
"carrying no token at all is refused 400, because a saved method with nothing chargeable "+
|
||||
"behind it looks saved and cannot be billed. The reference you send is NOT what comes "+
|
||||
"back — on a successful vaulting it is REPLACED by the processor's durable card id, and "+
|
||||
"that is the value later charges use. The billing address is stored exactly as sent and "+
|
||||
"is not verified here. A declined card is 402 carrying a humanized reason. The subject is "+
|
||||
"pinned to the validated caller, so a card can only ever be attached to the caller's own "+
|
||||
"account whatever the body claims — and posting the same card twice creates two rows, "+
|
||||
"because there is no dedupe.")
|
||||
|
||||
openapi.Describe("/v1/billing/methods/:id", http.MethodDelete,
|
||||
"Remove a saved payment method",
|
||||
"Detaches the addressed method: the local row is soft-deleted and, when that row carries the "+
|
||||
"processor's customer and card ids, the card is withdrawn from the processor's vault too. "+
|
||||
"Answers a deleted flag with the id. Read that flag precisely — it is a claim about the "+
|
||||
"LOCAL row. The upstream removal is best-effort, and a method saved by the older "+
|
||||
"pre-authorization path carries no vault ids at all, so nothing is withdrawn upstream "+
|
||||
"while the response still says deleted. The id is resolved inside the caller's own "+
|
||||
"namespace and against the caller's own subject, so another tenant's or another subject's "+
|
||||
"id is 404 and never 403 — the id space cannot be probed. Removing the card an "+
|
||||
"auto-recharge or a running subscription bills leaves that arrangement with nothing to "+
|
||||
"charge; that is the customer's call to make.")
|
||||
|
||||
openapi.Describe("/v1/billing/portal/methods", http.MethodPost,
|
||||
"Save a card against a subject's account — the portal attach",
|
||||
"Vaults a tokenized card and stores its masked descriptor: the service-token twin of the "+
|
||||
"customer's POST /v1/billing/methods, and the same handler behind both. It exists at its "+
|
||||
"own address for the reason the portal list does — the app publishing the customer "+
|
||||
"address proxies here rather than into its own route. Every rule of the customer path "+
|
||||
"holds: the PAN never arrives, only the nonce; a card-typed body with no token is 400; "+
|
||||
"the reference you send is replaced by the processor's durable card id when vaulting "+
|
||||
"succeeds; a decline is 402. What differs is who may name the subject — an ordinary "+
|
||||
"caller is pinned to its own, and only a caller holding the internal service token may "+
|
||||
"act for another subject, within the org the gateway pinned. Cross-org is closed by the "+
|
||||
"namespace for both.")
|
||||
|
||||
openapi.Describe("/v1/billing/webhooks/:provider", http.MethodPost,
|
||||
"Payment-provider webhook intake for settlement and subscription lifecycle events",
|
||||
"Accepts a payment provider's event, verifies it, records it for audit, and applies "+
|
||||
@@ -853,3 +986,293 @@ func describeCheckout() {
|
||||
"unloadable store still 500. It is the same handler as the unprefixed cancel address, "+
|
||||
"with the same outcome.")
|
||||
}
|
||||
|
||||
// ---- /v1/commerce/<kind> — the merchant resource table ----
|
||||
//
|
||||
// Seventeen kinds, seven operations each, and ONE handler behind all 119.
|
||||
// commerce's generic REST scaffold (util/rest) binds create, list, get, replace,
|
||||
// patch, delete and a method-override tunnel for every model handed to it, so
|
||||
// these are not seventeen behaviours that happen to resemble each other — they
|
||||
// are one behaviour, and the kind supplies what the row IS and who may touch it.
|
||||
// The mechanics are therefore written once and composed with the kind, because
|
||||
// the alternative is seventeen hand-copied paragraphs describing one generator,
|
||||
// which is the drift DescribeRest already exists to prevent one level down.
|
||||
//
|
||||
// Three facts hold for all 119, so they are stated here instead of seventeen
|
||||
// times below:
|
||||
//
|
||||
// - EVERY ROW IS THE CALLER'S OWN. The scaffold builds each entity against the
|
||||
// caller org's own namespaced store, keyed by the namespace resolved from
|
||||
// the gateway-validated X-Org-Id. Reads and writes are both inside that
|
||||
// store, so another tenant's row is not withheld by a policy check — it is
|
||||
// not there at all. That is why every miss below is 404 and never 403.
|
||||
// - EVERY JSON FIELD IS CLIENT-WRITABLE. Create, replace and patch decode the
|
||||
// request body straight onto the model with no per-field allowlist. Where a
|
||||
// field is derived, ignored or overwritten anyway, the kind says so.
|
||||
// - THE PER-KIND PERMISSION TABLE IS NOT UNIVERSAL, and this is the one most
|
||||
// worth knowing. util/rest carries scopes for collection, product, return,
|
||||
// subscriber and variant only. For the other twelve the scaffold finds no
|
||||
// entry, logs that it is skipping the check, and ALLOWS — so on those kinds
|
||||
// the route's own gate is the entire authorization story. Each kind says
|
||||
// which of the two it is.
|
||||
type merchantKind struct {
|
||||
kind string // URL segment; the id parameter is <kind>id
|
||||
noun string // singular, for the summaries
|
||||
plural string // plural, for the list summary
|
||||
what string // what the row IS — the sentence a caller needs before any verb
|
||||
sortBy string // the field the list orders by when sort is not given
|
||||
scope string // per-kind permission scope; "" when the table has no entry
|
||||
admin bool // the route demands the ADMIN permission, not merely a valid token
|
||||
paywalled bool // the route also runs the commerce-admin entitlement gate
|
||||
}
|
||||
|
||||
// gate is the authorization sentence shared by all seven of a kind's operations:
|
||||
// who reaches the route at all, and — for the paywalled kinds — what the org must
|
||||
// hold to be admitted past it.
|
||||
func (m merchantKind) gate() string {
|
||||
s := "Any valid access token reaches it."
|
||||
if m.admin {
|
||||
s = "The token must carry the ADMIN permission; an ordinary access token is refused."
|
||||
}
|
||||
if m.paywalled {
|
||||
s += " The org must also be entitled to the commerce admin: the paywall answers 402 " +
|
||||
"subscription_required unless the org holds an active or trialing pro subscription, a live " +
|
||||
"trial credit or a redeemed invite, and 503 when that entitlement cannot be read rather " +
|
||||
"than admitting on an unknown. The internal service token and a platform superadmin pass " +
|
||||
"straight through."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// also states the SECOND check — the per-kind scope the scaffold applies on top of
|
||||
// the route's gate — or says plainly that this kind has none, which is a fact about
|
||||
// the authorization a caller would otherwise have to assume.
|
||||
func (m merchantKind) also(need string) string {
|
||||
if m.scope == "" {
|
||||
return " The per-kind permission table has no entry for " + m.kind + ", so the scaffold skips " +
|
||||
"that second check with a warning and the gate above is the whole authorization story."
|
||||
}
|
||||
return " The token must also carry " + need + "."
|
||||
}
|
||||
|
||||
func (m merchantKind) describe() {
|
||||
root := "/v1/commerce/" + m.kind + "/"
|
||||
one := root + ":" + m.kind + "id"
|
||||
write := "Admin or Write" + m.scope
|
||||
edit := "Admin, or Read" + m.scope + " and Write" + m.scope + " together"
|
||||
|
||||
openapi.Describe(root, http.MethodGet,
|
||||
"List your org's "+m.plural+", as a page",
|
||||
m.what+" Answers a pagination envelope — the page and display echoed back, the rows under "+
|
||||
"models, a total count and a facets array — read from the caller org's own namespaced "+
|
||||
"store, so one tenant can never list another's. Sorting defaults to "+m.sortBy+" and is "+
|
||||
"overridable with sort. display is the page size and page applies only alongside it; "+
|
||||
"either one that is not a positive integer is refused with 500 rather than silently "+
|
||||
"ignored, and the limit query overrides the reported COUNT only, never the rows returned. "+
|
||||
"No search backend is wired, so the datastore is the one and only list path and facets is "+
|
||||
"always empty. A request resolving no org namespace is served an EMPTY page rather than "+
|
||||
"an unscoped scan: the namespace IS the tenant filter, so without one there is nothing "+
|
||||
"safe to return. "+m.gate()+m.also("Admin or the "+m.scope+" list scope"))
|
||||
|
||||
openapi.Describe(root, http.MethodPost,
|
||||
"Create a "+m.noun,
|
||||
m.what+" Decodes the body into a new row in the caller org's own namespaced store — isolated "+
|
||||
"to that tenant from its first write — and answers the stored row at 201 with a Location "+
|
||||
"header naming its id. The id is assigned by the store, not taken from the body. A body "+
|
||||
"that fails to decode is 400 and a store that refuses the write is 500. "+
|
||||
m.gate()+m.also(write))
|
||||
|
||||
openapi.Describe(one, http.MethodGet,
|
||||
"Fetch one "+m.noun,
|
||||
m.what+" Reads the addressed row from the caller org's own namespaced store. An id that is "+
|
||||
"not there is 404 — and another tenant's id is not there by construction, so it reads "+
|
||||
"exactly like a typo instead of confirming the row exists somewhere else. "+
|
||||
m.gate()+m.also("Admin or Read"+m.scope))
|
||||
|
||||
openapi.Describe(one, http.MethodPut,
|
||||
"Replace a "+m.noun+" outright",
|
||||
m.what+" This is a true REPLACEMENT, not a merge: the stored row's key is preserved, but the "+
|
||||
"body is decoded onto a FRESH entity, so every field the body omits is written back as "+
|
||||
"its ZERO value. Patch is the verb for changing part of a row. The id is resolved inside "+
|
||||
"the caller org's own namespace and an absent one is 404 before anything is written; a "+
|
||||
"body that fails to decode is 400. Answers the stored result. "+m.gate()+m.also(edit))
|
||||
|
||||
openapi.Describe(one, http.MethodPatch,
|
||||
"Change part of a "+m.noun,
|
||||
m.what+" Loads the stored row and decodes the body OVER it, so only the fields the body names "+
|
||||
"change and everything else keeps its stored value — the difference from the full "+
|
||||
"replace, which clears what it is not told. Answers the merged row. An id absent from the "+
|
||||
"caller org's namespace is 404 and a body that fails to decode is 400. "+
|
||||
m.gate()+m.also(edit))
|
||||
|
||||
openapi.Describe(one, http.MethodPost,
|
||||
"Method-override tunnel for a "+m.noun+" — for clients that cannot send PUT, PATCH or DELETE",
|
||||
m.what+" Re-dispatches the request into the handler the intended verb would have reached, "+
|
||||
"taking that verb from a _method form value or query parameter and then from the "+
|
||||
"X-HTTP-Method-Override header. PUT replaces the row, PATCH changes part of it, DELETE "+
|
||||
"removes it, and anything else is 405. The trap is the DEFAULT: naming no override at all "+
|
||||
"leaves the method POST, which this tunnel maps to the PARTIAL UPDATE — it is never a "+
|
||||
"create, and creating is the collection root's job. Behaviour and authorization are the "+
|
||||
"underlying operation's, since the real handler runs. "+m.gate())
|
||||
|
||||
openapi.Describe(one, http.MethodDelete,
|
||||
"Delete a "+m.noun+", keeping a recoverable copy",
|
||||
m.what+" Removes the addressed row and answers 204 with no body. Before the live row goes it "+
|
||||
"is written once more under a deleted tombstone kind, so a deletion leaves a recoverable "+
|
||||
"copy rather than destroying the record outright — and a tombstone that cannot be written "+
|
||||
"fails the call with 500 before anything is removed. The id is resolved inside the caller "+
|
||||
"org's own namespace, so an absent or foreign id is 404. "+m.gate()+m.also(write))
|
||||
}
|
||||
|
||||
func describeMerchant() {
|
||||
for _, m := range merchantKinds {
|
||||
m.describe()
|
||||
}
|
||||
}
|
||||
|
||||
var merchantKinds = []merchantKind{{
|
||||
kind: "collection", noun: "collection", plural: "collections",
|
||||
sortBy: "the slug", scope: "Collection", paywalled: true,
|
||||
what: "A collection is a merchandising group a storefront renders — a slug and name, copy and " +
|
||||
"media, flat lists of the product and variant ids it holds, published, preorder and " +
|
||||
"out-of-stock flags, and an availability window. Membership lives on the collection as those " +
|
||||
"id lists rather than as a join, so putting a product into a collection is a write here and " +
|
||||
"not on the product.",
|
||||
}, {
|
||||
kind: "discount", noun: "discount", plural: "discounts",
|
||||
sortBy: "the last-updated time", paywalled: true,
|
||||
what: "A discount is a price rule: a type (flat, percent, free-shipping, free-item or bulk), a " +
|
||||
"window, a scope naming the store, collection, product or variant it applies to, a target, " +
|
||||
"and rules pairing a trigger — a price or quantity threshold — with an action, an amount off " +
|
||||
"or a percentage. It is ENABLED BY DEFAULT, so a bare create makes a live discount rather " +
|
||||
"than a draft. The rule engine caches per replica for about thirty seconds, so a discount " +
|
||||
"switched off here can keep applying briefly on other replicas.",
|
||||
}, {
|
||||
kind: "disclosure", noun: "disclosure", plural: "disclosures",
|
||||
sortBy: "the last-updated time",
|
||||
what: "A disclosure is a published-document record — a publication body, a content hash, a type " +
|
||||
"and a named receiver. The hash LOOKS like a field you set and is in fact derived, but only " +
|
||||
"on update: a freshly created disclosure keeps whatever hash the caller sent until the first " +
|
||||
"replace or patch recomputes it, so a new row's hash attests to nothing. This kind lives in " +
|
||||
"commerce's demo tree — a live writable resource in your tenant's real store that nothing " +
|
||||
"else in commerce reads.",
|
||||
}, {
|
||||
kind: "movie", noun: "movie", plural: "movies",
|
||||
sortBy: "the slug",
|
||||
what: "A movie is a film catalog record — a slug plus EIDR and IMDB ids, all three required, with " +
|
||||
"title and synopsis copy, artwork, screenshots, trailers, cast and crew, and available and " +
|
||||
"hidden flags. It carries NO price: the money for a film lives on the product that sells it.",
|
||||
}, {
|
||||
kind: "note", noun: "note", plural: "notes",
|
||||
sortBy: "the last-updated time",
|
||||
what: "A note is a timestamped free-text log line — a caller-supplied time, a source, a message " +
|
||||
"and an enabled flag. That time is the caller's own field and is distinct from the row's " +
|
||||
"creation stamp; the note search filters on it, so a note written without one is a zero-time " +
|
||||
"note the ops log will never surface.",
|
||||
}, {
|
||||
kind: "product", noun: "product", plural: "products",
|
||||
sortBy: "the slug", scope: "Product", paywalled: true,
|
||||
what: "A product is a sellable catalog item: slug, SKU and UPC, name and copy, media, " +
|
||||
"availability and preorder flags, a reservation block, and its money — currency, price, " +
|
||||
"MSRP, list price and inventory cost in minor units, inventory count, taxability, and the " +
|
||||
"subscription interval when it is subscribeable. Its variants and options are carried as a " +
|
||||
"denormalized JSON snapshot inside the product, separate from the standalone variant rows, " +
|
||||
"and nothing keeps the two in step for you.",
|
||||
}, {
|
||||
kind: "return", noun: "return", plural: "returns",
|
||||
sortBy: "the last-updated time", scope: "Return",
|
||||
what: "A return is an RMA — the store, user and order it belongs to, the line items coming back, " +
|
||||
"a fulfillment block carrying its own type, status and pricing, a summary, and eight " +
|
||||
"lifecycle timestamps from submitted through delivered and processed. Its status is a FREE " +
|
||||
"STRING with no enumeration behind it, and there is no refund amount on the return itself: " +
|
||||
"the money sits inside the line items and the fulfillment pricing.",
|
||||
}, {
|
||||
kind: "saleschannel", noun: "sales channel", plural: "sales channels",
|
||||
sortBy: "the last-updated time", paywalled: true,
|
||||
what: "A sales channel is a named selling surface — a name, a description, a disabled flag and " +
|
||||
"metadata. The flag is NEGATIVE, so a channel created from an empty body is enabled. Nothing " +
|
||||
"on this row links products, prices or stock to the channel; here it is a label other " +
|
||||
"surfaces scope themselves by.",
|
||||
}, {
|
||||
kind: "stocklocation", noun: "stock location", plural: "stock locations",
|
||||
sortBy: "the last-updated time", paywalled: true,
|
||||
what: "A stock location is a physical address inventory can be held at — a name, street lines, " +
|
||||
"city, province, country, postal code and a phone. None of it is validated, there are no " +
|
||||
"coordinates, and the row carries no enabled flag and no inventory link, so deleting it is " +
|
||||
"the only way to retire one.",
|
||||
}, {
|
||||
kind: "submission", noun: "submission", plural: "submissions",
|
||||
sortBy: "the last-updated time",
|
||||
what: "A submission is one filled-in form from a site visitor — an email, an optional user id, the " +
|
||||
"client details the server observed (user agent, referer, geography) and the form's own " +
|
||||
"fields as free metadata. It carries no form id, so the link back to the form that produced " +
|
||||
"it is not stored on the row.",
|
||||
}, {
|
||||
kind: "subscriber", noun: "subscriber", plural: "subscribers",
|
||||
sortBy: "the last-updated time", scope: "Subscriber",
|
||||
what: "A subscriber is a mailing-list member — name, email, the form id that captured them, " +
|
||||
"unsubscribed state and date, client details, tags and metadata. Writing one FIRES A " +
|
||||
"WEBHOOK: subscriber.created on create and subscriber.updated on replace or patch, emitted " +
|
||||
"BEFORE the write is known to have succeeded and carrying the row as sent, so the payload " +
|
||||
"holds the raw email rather than the normalized one that gets stored.",
|
||||
}, {
|
||||
kind: "tokentransaction", noun: "token transaction", plural: "token transactions",
|
||||
sortBy: "the last-updated time",
|
||||
what: "A token transaction records a transfer between two identified parties — amount and fees, a " +
|
||||
"timestamp, sending and receiving addresses, names, user ids, states and countries, a flag " +
|
||||
"per side, a protocol name and a transaction hash. Nothing here touches a chain: the hash is " +
|
||||
"an unvalidated string and the flags are plain writable booleans with no screening behind " +
|
||||
"them. Amounts are floating-point rather than the exact minor units every real money field " +
|
||||
"in commerce uses, and there is no currency field at all — this kind lives in commerce's " +
|
||||
"demo tree, so it is a live writable resource in your tenant's store that nothing else in " +
|
||||
"commerce reads, and it must never carry real money.",
|
||||
}, {
|
||||
kind: "transfer", noun: "transfer", plural: "transfers",
|
||||
sortBy: "the last-updated time", admin: true,
|
||||
what: "A transfer records that a payable WAS PAID — the annotation a human writes after paying " +
|
||||
"out of band. Commerce executes no payout: creating one moves no money, and it marks the " +
|
||||
"referenced payable settled. It carries the payable and payee ids, the amount it settles and " +
|
||||
"the amount actually sent (which may be a different asset), a type of eth, wire or other, " +
|
||||
"the transaction hash or wire reference, when it was paid and who recorded it; amounts are " +
|
||||
"exact decimal strings with an asset, not cents. It is admin-gated because writing one " +
|
||||
"settles money we owe, and nothing enforces uniqueness on the reference — so posting the " +
|
||||
"same transfer twice settles the payable twice.",
|
||||
}, {
|
||||
kind: "variant", noun: "variant", plural: "variants",
|
||||
sortBy: "the SKU", scope: "Variant", paywalled: true,
|
||||
what: "A variant is one purchasable SKU of a product — its product id, SKU and UPC, name, media, " +
|
||||
"availability, the option name and value pairs that distinguish it, a sold counter, and its " +
|
||||
"own money and stock: currency, price, MSRP, inventory cost, inventory count and taxability. " +
|
||||
"Inventory and sold are plain writable numbers with no decrement logic behind them here. The " +
|
||||
"same variant also exists as a JSON copy inside its product, and writing one does not update " +
|
||||
"the other.",
|
||||
}, {
|
||||
kind: "wallet", noun: "wallet", plural: "wallets",
|
||||
sortBy: "the last-updated time", admin: true,
|
||||
what: "A wallet is a container of custodial blockchain accounts, and its only field is that " +
|
||||
"account list — each account carrying a name, an address, a chain type, and the ENCRYPTED " +
|
||||
"private key with its salt. Creating a wallet through this table generates NO KEYS: key " +
|
||||
"generation lives on the account routes, so a wallet made here is an empty shell and an " +
|
||||
"account posted into one is stored exactly as sent, with no key generation and no validation " +
|
||||
"behind it. Know what a read renders: the plaintext private key is never marshalled and " +
|
||||
"never stored, but the encrypted blob and its salt ARE returned, so whoever can read a " +
|
||||
"wallet can attack it offline down to the strength of the owner's passphrase. That is why " +
|
||||
"this kind is admin-gated.",
|
||||
}, {
|
||||
kind: "watchlist", noun: "watchlist", plural: "watchlists",
|
||||
sortBy: "the last-updated time",
|
||||
what: "A watchlist is a viewer's saved list of movies — a user id, an email, and the movies " +
|
||||
"themselves. It stores WHOLE MOVIE SNAPSHOTS rather than movie ids, so a list goes stale the " +
|
||||
"moment a film record changes and grows without bound as it fills.",
|
||||
}, {
|
||||
kind: "webhook", noun: "webhook", plural: "webhooks",
|
||||
sortBy: "the last-updated time", admin: true,
|
||||
what: "A webhook is a merchant-registered endpoint that receives commerce event callbacks — a " +
|
||||
"name, a URL, live and all flags, a per-event map, an enabled flag, and the shared access " +
|
||||
"token each delivery posts IN THE BODY. Two things to know before registering one: that " +
|
||||
"token is a plainly readable field, so anyone who may read webhooks reads every endpoint's " +
|
||||
"secret, and delivery consults only the all flag and the event map — it does NOT consult " +
|
||||
"enabled or live, so setting enabled false does not stop delivery and deleting the row is " +
|
||||
"the only thing that does. Delivery is a single POST with a twenty-second timeout and no " +
|
||||
"retry.",
|
||||
}}
|
||||
|
||||
@@ -7,13 +7,6 @@ import (
|
||||
)
|
||||
|
||||
func init() {
|
||||
zip.Describe("GET /_/commerce/healthz", zip.Doc{
|
||||
Description: "Answers ok whenever the commerce subsystem is mounted. It is registered\nbefore the module embed boots, so it keeps answering even when the embed\nfailed and every business route serves the fail-closed 503 — which is the\npoint: it reports that the process is reachable, never that the money plane\nis healthy. Unauthenticated, and under /_ so the ingress withholds it\npublicly.\n\nA named handler, not a closure, so zipdoc can lift this prose into the registry.",
|
||||
Fields: map[string]string{
|
||||
"liveness.service": "Service names the answering subsystem; it is always commerce.",
|
||||
"liveness.status": "Status is always ok: mounted is the only state that can answer.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/billing/invoices/:id", zip.Doc{
|
||||
Description: "Reads one invoice out of the caller's org.\n\nThe org scopes the read by construction — the store is namespaced to it — so an\nid belonging to another tenant is not found rather than found and then filtered.\n\nA named handler, not a closure, so zipdoc can lift this prose into the registry.",
|
||||
Fields: map[string]string{
|
||||
|
||||
@@ -146,7 +146,7 @@ func init() {
|
||||
}
|
||||
|
||||
// apply is the UNAUTHENTICATED public application endpoint. It validates, drops
|
||||
// honeypot hits, dedups on (email, company), writes the Application (+ a
|
||||
// honeypot hits, dedups on (email, company), writes the ProgramApplication (+ a
|
||||
// best-effort CRM Company/Contact for sales visibility), and kicks off the AI
|
||||
// screen. It NEVER calls tenant(): the org is the fixed program org.
|
||||
func apply(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
@@ -203,7 +203,7 @@ func apply(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
if gerr != nil {
|
||||
return zip.Errorf(http.StatusInternalServerError, "rng: %v", gerr)
|
||||
}
|
||||
app := Application{
|
||||
app := ProgramApplication{
|
||||
ID: id, Org: org, Company: company, Website: clip(req.Website),
|
||||
ContactName: name, Email: email, Role: clip(req.Role),
|
||||
Stage: StageApplied, Tier1: tier1, Metadata: meta,
|
||||
@@ -224,9 +224,9 @@ func apply(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
|
||||
// projectToCRM creates a thin CRM Company + Contact linked to the application, so
|
||||
// the startup also appears in the org's standard CRM. Best-effort: any failure
|
||||
// is non-fatal (the Application remains the source of truth) and returns empty
|
||||
// is non-fatal (the ProgramApplication remains the source of truth) and returns empty
|
||||
// ids. Reuses the same store + referential-integrity rules as the CRM handlers.
|
||||
func projectToCRM(s *cloud.Service[state], ctx context.Context, org string, app Application, req applyRequest) (companyID, contactID string) {
|
||||
func projectToCRM(s *cloud.Service[state], ctx context.Context, org string, app ProgramApplication, req applyRequest) (companyID, contactID string) {
|
||||
now := time.Now().Unix()
|
||||
cid, err := genID("comp")
|
||||
if err != nil {
|
||||
@@ -300,7 +300,7 @@ type applicationPage struct {
|
||||
// applicationList is a page of the org's Startup Program applications.
|
||||
type applicationList struct {
|
||||
// Data is the page of applications, newest first.
|
||||
Data []Application `json:"data"`
|
||||
Data []ProgramApplication `json:"data"`
|
||||
}
|
||||
|
||||
// ListApplications returns the org's Startup Program applications, newest first.
|
||||
@@ -326,7 +326,7 @@ func (o ops) listApplications(ctx context.Context, in *applicationPage) (*applic
|
||||
// An id belonging to another org reads as not found.
|
||||
//
|
||||
// Example: {"id": "appl_1"}
|
||||
func (o ops) getApplication(ctx context.Context, in *ref) (*Application, error) {
|
||||
func (o ops) getApplication(ctx context.Context, in *ref) (*ProgramApplication, error) {
|
||||
org, err := tenant(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -361,7 +361,7 @@ type patchApplicationIn struct {
|
||||
// no stage change is still recorded.
|
||||
//
|
||||
// Example: {"id": "appl_1", "stage": "rejected", "reason": "not a fit this round"}
|
||||
func (o ops) patchApplication(ctx context.Context, in *patchApplicationIn) (*Application, error) {
|
||||
func (o ops) patchApplication(ctx context.Context, in *patchApplicationIn) (*ProgramApplication, error) {
|
||||
org, err := tenant(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -502,7 +502,7 @@ func runScreen(s *cloud.Service[state], ctx context.Context, org, id string) {
|
||||
saveScreen(s, ctx, app)
|
||||
}
|
||||
|
||||
func saveScreen(s *cloud.Service[state], ctx context.Context, app Application) {
|
||||
func saveScreen(s *cloud.Service[state], ctx context.Context, app ProgramApplication) {
|
||||
app.UpdatedAt = time.Now().Unix()
|
||||
if _, err := s.State.store.UpdateApplication(ctx, app); err != nil {
|
||||
s.Log.Warn("screen: save failed", "id", app.ID, "err", err)
|
||||
@@ -510,7 +510,7 @@ func saveScreen(s *cloud.Service[state], ctx context.Context, app Application) {
|
||||
}
|
||||
|
||||
// screenPrompt renders the strict-JSON scoring instruction for one application.
|
||||
func screenPrompt(app Application) string {
|
||||
func screenPrompt(app ProgramApplication) string {
|
||||
payload, _ := json.MarshalIndent(app.Metadata, "", " ")
|
||||
var b strings.Builder
|
||||
b.WriteString("You are an analyst screening applications to the Hanzo Startup Program, ")
|
||||
|
||||
@@ -82,12 +82,12 @@ type StageEvent struct {
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// Application is one startup-program submission plus its AI screen and pipeline
|
||||
// ProgramApplication is one startup-program submission plus its AI screen and pipeline
|
||||
// state. Metadata carries the FULL submitted payload (all form fields, including
|
||||
// arrays like tier1Investors/useCases); the promoted columns are query/display
|
||||
// projections. Tier1 is deterministically derived at intake from the submitted
|
||||
// fund list (independent of the AI screen's judgement).
|
||||
type Application struct {
|
||||
type ProgramApplication struct {
|
||||
// ID is the server-minted application id ("appl_" + 128 random bits).
|
||||
ID string `json:"id"`
|
||||
// Org is the owning tenant — the program org, which is the deployment brand.
|
||||
@@ -172,15 +172,15 @@ CREATE INDEX IF NOT EXISTS ix_crm_apps_org_email ON crm_applications(org, emai
|
||||
|
||||
const appCols = `id,org,company,website,contact_name,email,role,stage,tier1,metadata,screen,events,company_id,contact_id,reason,created_at,updated_at`
|
||||
|
||||
func scanApplication(sc interface{ Scan(...any) error }) (Application, error) {
|
||||
var a Application
|
||||
func scanApplication(sc interface{ Scan(...any) error }) (ProgramApplication, error) {
|
||||
var a ProgramApplication
|
||||
var tier1 int
|
||||
var meta, screen, events string
|
||||
err := sc.Scan(&a.ID, &a.Org, &a.Company, &a.Website, &a.ContactName, &a.Email,
|
||||
&a.Role, &a.Stage, &tier1, &meta, &screen, &events, &a.CompanyID, &a.ContactID,
|
||||
&a.Reason, &a.CreatedAt, &a.UpdatedAt)
|
||||
if err != nil {
|
||||
return Application{}, err
|
||||
return ProgramApplication{}, err
|
||||
}
|
||||
a.Tier1 = tier1 != 0
|
||||
a.Metadata = map[string]any{}
|
||||
@@ -206,7 +206,7 @@ func jsonOr(v any, fallback string) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (s *Store) CreateApplication(ctx context.Context, a Application) (Application, error) {
|
||||
func (s *Store) CreateApplication(ctx context.Context, a ProgramApplication) (ProgramApplication, error) {
|
||||
meta := jsonOr(a.Metadata, "{}")
|
||||
screen := jsonOr(a.Screen, "{}")
|
||||
events := jsonOr(a.Events, "[]")
|
||||
@@ -215,26 +215,26 @@ func (s *Store) CreateApplication(ctx context.Context, a Application) (Applicati
|
||||
a.ID, a.Org, a.Company, a.Website, a.ContactName, a.Email, a.Role, a.Stage,
|
||||
b2i(a.Tier1), meta, screen, events, a.CompanyID, a.ContactID, a.Reason,
|
||||
a.CreatedAt, a.UpdatedAt); err != nil {
|
||||
return Application{}, fmt.Errorf("insert application: %w", err)
|
||||
return ProgramApplication{}, fmt.Errorf("insert application: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetApplication(ctx context.Context, org, id string) (Application, error) {
|
||||
func (s *Store) GetApplication(ctx context.Context, org, id string) (ProgramApplication, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+appCols+` FROM crm_applications WHERE org=? AND id=?`, org, id)
|
||||
a, err := scanApplication(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Application{}, errNotFound
|
||||
return ProgramApplication{}, errNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Application{}, fmt.Errorf("get application: %w", err)
|
||||
return ProgramApplication{}, fmt.Errorf("get application: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ListApplications lists an org's applications, optionally filtered by pipeline
|
||||
// stage (stage=="" means all). Newest first.
|
||||
func (s *Store) ListApplications(ctx context.Context, org, stage string, limit int) ([]Application, error) {
|
||||
func (s *Store) ListApplications(ctx context.Context, org, stage string, limit int) ([]ProgramApplication, error) {
|
||||
var (
|
||||
rows *sql.Rows
|
||||
err error
|
||||
@@ -250,7 +250,7 @@ func (s *Store) ListApplications(ctx context.Context, org, stage string, limit i
|
||||
return nil, fmt.Errorf("list applications: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := make([]Application, 0, 16)
|
||||
out := make([]ProgramApplication, 0, 16)
|
||||
for rows.Next() {
|
||||
a, err := scanApplication(rows)
|
||||
if err != nil {
|
||||
@@ -264,24 +264,24 @@ func (s *Store) ListApplications(ctx context.Context, org, stage string, limit i
|
||||
// FindApplicationByEmailCompany returns the org's application matching a
|
||||
// case-insensitive (email, company) pair, or errNotFound. Basis for idempotent
|
||||
// intake (a resubmission updates rather than duplicates).
|
||||
func (s *Store) FindApplicationByEmailCompany(ctx context.Context, org, email, company string) (Application, error) {
|
||||
func (s *Store) FindApplicationByEmailCompany(ctx context.Context, org, email, company string) (ProgramApplication, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT `+appCols+` FROM crm_applications
|
||||
WHERE org=? AND lower(email)=lower(?) AND lower(company)=lower(?)
|
||||
ORDER BY created_at DESC LIMIT 1`, org, email, company)
|
||||
a, err := scanApplication(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Application{}, errNotFound
|
||||
return ProgramApplication{}, errNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Application{}, fmt.Errorf("find application: %w", err)
|
||||
return ProgramApplication{}, fmt.Errorf("find application: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// UpdateApplication persists the mutable columns (stage, tier1, metadata,
|
||||
// screen, events, links, reason). ID/Org/CreatedAt are immutable keys.
|
||||
func (s *Store) UpdateApplication(ctx context.Context, a Application) (Application, error) {
|
||||
func (s *Store) UpdateApplication(ctx context.Context, a ProgramApplication) (ProgramApplication, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE crm_applications
|
||||
SET company=?,website=?,contact_name=?,email=?,role=?,stage=?,tier1=?,
|
||||
@@ -291,10 +291,10 @@ func (s *Store) UpdateApplication(ctx context.Context, a Application) (Applicati
|
||||
jsonOr(a.Metadata, "{}"), jsonOr(a.Screen, "{}"), jsonOr(a.Events, "[]"),
|
||||
a.CompanyID, a.ContactID, a.Reason, a.UpdatedAt, a.Org, a.ID)
|
||||
if err != nil {
|
||||
return Application{}, fmt.Errorf("update application: %w", err)
|
||||
return ProgramApplication{}, fmt.Errorf("update application: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return Application{}, errNotFound
|
||||
return ProgramApplication{}, errNotFound
|
||||
}
|
||||
return s.GetApplication(ctx, a.Org, a.ID)
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ func TestApplyCreatesRecords(t *testing.T) {
|
||||
// Staff read (org=hanzo, validated principal).
|
||||
code, body = do(t, app, http.MethodGet, "/v1/crm/applications", "hanzo", nil)
|
||||
var list struct {
|
||||
Data []Application `json:"data"`
|
||||
Data []ProgramApplication `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &list)
|
||||
if code != http.StatusOK || len(list.Data) != 1 {
|
||||
@@ -126,7 +126,7 @@ func TestApplyHoneypot(t *testing.T) {
|
||||
}
|
||||
_, lb := do(t, app, http.MethodGet, "/v1/crm/applications", "hanzo", nil)
|
||||
var list struct {
|
||||
Data []Application `json:"data"`
|
||||
Data []ProgramApplication `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(lb, &list)
|
||||
if len(list.Data) != 0 {
|
||||
@@ -173,7 +173,7 @@ func TestApplyIdempotent(t *testing.T) {
|
||||
}
|
||||
_, lb := do(t, app, http.MethodGet, "/v1/crm/applications", "hanzo", nil)
|
||||
var list struct {
|
||||
Data []Application `json:"data"`
|
||||
Data []ProgramApplication `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(lb, &list)
|
||||
if len(list.Data) != 1 {
|
||||
@@ -196,7 +196,7 @@ func TestApplyScreenEndToEnd(t *testing.T) {
|
||||
}
|
||||
_, lb := do(t, app, http.MethodGet, "/v1/crm/applications", "hanzo", nil)
|
||||
var list struct {
|
||||
Data []Application `json:"data"`
|
||||
Data []ProgramApplication `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(lb, &list)
|
||||
if len(list.Data) != 1 {
|
||||
@@ -233,7 +233,7 @@ func TestScreenNonFatal(t *testing.T) {
|
||||
}
|
||||
_, lb := do(t, app, http.MethodGet, "/v1/crm/applications", "hanzo", nil)
|
||||
var list struct {
|
||||
Data []Application `json:"data"`
|
||||
Data []ProgramApplication `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(lb, &list)
|
||||
if len(list.Data) != 1 {
|
||||
@@ -281,7 +281,7 @@ func TestPatchStageMachine(t *testing.T) {
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("reject with reason want 200, got %d (%s)", code, rb)
|
||||
}
|
||||
var rejected Application
|
||||
var rejected ProgramApplication
|
||||
_ = json.Unmarshal(rb, &rejected)
|
||||
if rejected.Stage != StageRejected || rejected.Reason != "not a fit this round" {
|
||||
t.Fatalf("reject state mismatch: %+v", rejected)
|
||||
|
||||
@@ -150,7 +150,7 @@ func TestEveryTypedOpIsDescribed(t *testing.T) {
|
||||
// doc comments on the In/Out struct fields, which zipdoc lifts per field. crm
|
||||
// shipped fully-described request types (companyReq, patchApplicationIn, …) beside
|
||||
// RESPONSE types with 65 bare properties: every field of Company, Contact,
|
||||
// Opportunity, Application, ScreenResult and StageEvent reached openapi.yaml, the
|
||||
// Opportunity, ProgramApplication, ScreenResult and StageEvent reached openapi.yaml, the
|
||||
// generated SDKs and the MCP inputSchemas with no description at all, because those
|
||||
// are store row types that nobody had written field prose on. A reader of the API
|
||||
// could see that `arr` is an integer and nowhere that it is CENTS.
|
||||
|
||||
+98
-98
@@ -33,75 +33,75 @@ func init() {
|
||||
zip.Describe("GET /v1/crm/applications", zip.Doc{
|
||||
Description: "Returns the org's Startup Program applications, newest first.\nEach carries its AI screen and its stage history; a stage narrows the page to\none pipeline stage.",
|
||||
Fields: map[string]string{
|
||||
"Application.company": "Company is the applicant's company name.",
|
||||
"Application.companyId": "CompanyID is the CRM Company minted for this lead at intake, so the startup\nalso appears in the org's standard CRM tabs. Empty when that best-effort\nprojection did not run.",
|
||||
"Application.contactId": "ContactID is the CRM Contact minted for this lead at intake. Empty when that\nbest-effort projection did not run.",
|
||||
"Application.contactName": "ContactName is the person who applied.",
|
||||
"Application.createdAt": "CreatedAt is the unix second the application arrived. Server-owned.",
|
||||
"Application.email": "Email is the applicant's email — half of the (email, company) key a\nresubmission refreshes instead of duplicating.",
|
||||
"Application.events": "Events is the append-only stage-transition log, oldest first.",
|
||||
"Application.id": "ID is the server-minted application id (\"appl_\" + 128 random bits).",
|
||||
"Application.metadata": "Metadata is the FULL submitted form, every field, including the arrays the\npromoted columns above do not carry (tier1Investors, useCases) and the\ndeterministic tier1Matched list.",
|
||||
"Application.reason": "Reason is why the application was rejected, required to reject. Empty\notherwise.",
|
||||
"Application.role": "Role is the applicant's role at their company.",
|
||||
"Application.screen": "Screen is the AI screen. It runs after intake, so a freshly created\napplication carries a \"pending\" screen.",
|
||||
"Application.stage": "Stage is the pipeline stage: applied, screened, qualified, credits-offered,\nonboarded or rejected. Server-owned — it starts at \"applied\" and moves only\nthrough the transition machine.",
|
||||
"Application.tier1": "Tier1 is whether the applicant is tier-1 backed, derived deterministically\nat intake from the submitted fund list — independent of the AI screen.",
|
||||
"Application.updatedAt": "UpdatedAt is the unix second of the last write. Server-owned.",
|
||||
"Application.website": "Website is the applicant's website as submitted.",
|
||||
"ScreenResult.draftReply": "DraftReply is a suggested email reply for staff to edit and send.",
|
||||
"ScreenResult.error": "Error says why a failed screen failed — no AI gateway configured, a gateway\nerror, or a reply that carried no parseable JSON. Absent on success.",
|
||||
"ScreenResult.model": "Model is the LLM the screen ran on.",
|
||||
"ScreenResult.score": "Score is the model's 0..100 fit score, clamped to that range.",
|
||||
"ScreenResult.screenedAt": "ScreenedAt is the unix second the screen finished (0 while pending).",
|
||||
"ScreenResult.status": "Status is the screen's state: pending | done | failed.",
|
||||
"ScreenResult.suggestedCredits": "SuggestedCredits is the recommended credit grant in USD, snapped to the\nnearest allowed rung: 0 | 5000 | 25000 | 50000 | 150000.",
|
||||
"ScreenResult.summary": "Summary is the model's short assessment of the application.",
|
||||
"ScreenResult.tier1Backed": "Tier1Backed is the model's read on tier-1 backing, normalized to\n\"yes\", \"no\" or \"unclear\" (anything it cannot resolve reads \"unclear\").",
|
||||
"StageEvent.at": "At is the unix second of the move.",
|
||||
"StageEvent.by": "By is who moved it: \"system\" for intake and the AI auto-advance, else the\nvalidated staff user id.",
|
||||
"StageEvent.from": "From is the stage moved out of; empty on the intake event that opens the log.",
|
||||
"StageEvent.note": "Note is the free-text comment recorded with the move. Absent when none.",
|
||||
"StageEvent.to": "To is the stage moved into.",
|
||||
"applicationList.data": "Data is the page of applications, newest first.",
|
||||
"applicationPage.limit": "Limit caps the rows returned: 200 by default, 1000 at most.",
|
||||
"applicationPage.stage": "Stage returns only the applications at that pipeline stage when set:\napplied, screened, qualified, credits-offered, onboarded or rejected.",
|
||||
"ProgramApplication.company": "Company is the applicant's company name.",
|
||||
"ProgramApplication.companyId": "CompanyID is the CRM Company minted for this lead at intake, so the startup\nalso appears in the org's standard CRM tabs. Empty when that best-effort\nprojection did not run.",
|
||||
"ProgramApplication.contactId": "ContactID is the CRM Contact minted for this lead at intake. Empty when that\nbest-effort projection did not run.",
|
||||
"ProgramApplication.contactName": "ContactName is the person who applied.",
|
||||
"ProgramApplication.createdAt": "CreatedAt is the unix second the application arrived. Server-owned.",
|
||||
"ProgramApplication.email": "Email is the applicant's email — half of the (email, company) key a\nresubmission refreshes instead of duplicating.",
|
||||
"ProgramApplication.events": "Events is the append-only stage-transition log, oldest first.",
|
||||
"ProgramApplication.id": "ID is the server-minted application id (\"appl_\" + 128 random bits).",
|
||||
"ProgramApplication.metadata": "Metadata is the FULL submitted form, every field, including the arrays the\npromoted columns above do not carry (tier1Investors, useCases) and the\ndeterministic tier1Matched list.",
|
||||
"ProgramApplication.reason": "Reason is why the application was rejected, required to reject. Empty\notherwise.",
|
||||
"ProgramApplication.role": "Role is the applicant's role at their company.",
|
||||
"ProgramApplication.screen": "Screen is the AI screen. It runs after intake, so a freshly created\napplication carries a \"pending\" screen.",
|
||||
"ProgramApplication.stage": "Stage is the pipeline stage: applied, screened, qualified, credits-offered,\nonboarded or rejected. Server-owned — it starts at \"applied\" and moves only\nthrough the transition machine.",
|
||||
"ProgramApplication.tier1": "Tier1 is whether the applicant is tier-1 backed, derived deterministically\nat intake from the submitted fund list — independent of the AI screen.",
|
||||
"ProgramApplication.updatedAt": "UpdatedAt is the unix second of the last write. Server-owned.",
|
||||
"ProgramApplication.website": "Website is the applicant's website as submitted.",
|
||||
"ScreenResult.draftReply": "DraftReply is a suggested email reply for staff to edit and send.",
|
||||
"ScreenResult.error": "Error says why a failed screen failed — no AI gateway configured, a gateway\nerror, or a reply that carried no parseable JSON. Absent on success.",
|
||||
"ScreenResult.model": "Model is the LLM the screen ran on.",
|
||||
"ScreenResult.score": "Score is the model's 0..100 fit score, clamped to that range.",
|
||||
"ScreenResult.screenedAt": "ScreenedAt is the unix second the screen finished (0 while pending).",
|
||||
"ScreenResult.status": "Status is the screen's state: pending | done | failed.",
|
||||
"ScreenResult.suggestedCredits": "SuggestedCredits is the recommended credit grant in USD, snapped to the\nnearest allowed rung: 0 | 5000 | 25000 | 50000 | 150000.",
|
||||
"ScreenResult.summary": "Summary is the model's short assessment of the application.",
|
||||
"ScreenResult.tier1Backed": "Tier1Backed is the model's read on tier-1 backing, normalized to\n\"yes\", \"no\" or \"unclear\" (anything it cannot resolve reads \"unclear\").",
|
||||
"StageEvent.at": "At is the unix second of the move.",
|
||||
"StageEvent.by": "By is who moved it: \"system\" for intake and the AI auto-advance, else the\nvalidated staff user id.",
|
||||
"StageEvent.from": "From is the stage moved out of; empty on the intake event that opens the log.",
|
||||
"StageEvent.note": "Note is the free-text comment recorded with the move. Absent when none.",
|
||||
"StageEvent.to": "To is the stage moved into.",
|
||||
"applicationList.data": "Data is the page of applications, newest first.",
|
||||
"applicationPage.limit": "Limit caps the rows returned: 200 by default, 1000 at most.",
|
||||
"applicationPage.stage": "Stage returns only the applications at that pipeline stage when set:\napplied, screened, qualified, credits-offered, onboarded or rejected.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/crm/applications/:id", zip.Doc{
|
||||
Description: "Returns one Startup Program application with its AI screen and stage history.\nAn id belonging to another org reads as not found.",
|
||||
Fields: map[string]string{
|
||||
"Application.company": "Company is the applicant's company name.",
|
||||
"Application.companyId": "CompanyID is the CRM Company minted for this lead at intake, so the startup\nalso appears in the org's standard CRM tabs. Empty when that best-effort\nprojection did not run.",
|
||||
"Application.contactId": "ContactID is the CRM Contact minted for this lead at intake. Empty when that\nbest-effort projection did not run.",
|
||||
"Application.contactName": "ContactName is the person who applied.",
|
||||
"Application.createdAt": "CreatedAt is the unix second the application arrived. Server-owned.",
|
||||
"Application.email": "Email is the applicant's email — half of the (email, company) key a\nresubmission refreshes instead of duplicating.",
|
||||
"Application.events": "Events is the append-only stage-transition log, oldest first.",
|
||||
"Application.id": "ID is the server-minted application id (\"appl_\" + 128 random bits).",
|
||||
"Application.metadata": "Metadata is the FULL submitted form, every field, including the arrays the\npromoted columns above do not carry (tier1Investors, useCases) and the\ndeterministic tier1Matched list.",
|
||||
"Application.reason": "Reason is why the application was rejected, required to reject. Empty\notherwise.",
|
||||
"Application.role": "Role is the applicant's role at their company.",
|
||||
"Application.screen": "Screen is the AI screen. It runs after intake, so a freshly created\napplication carries a \"pending\" screen.",
|
||||
"Application.stage": "Stage is the pipeline stage: applied, screened, qualified, credits-offered,\nonboarded or rejected. Server-owned — it starts at \"applied\" and moves only\nthrough the transition machine.",
|
||||
"Application.tier1": "Tier1 is whether the applicant is tier-1 backed, derived deterministically\nat intake from the submitted fund list — independent of the AI screen.",
|
||||
"Application.updatedAt": "UpdatedAt is the unix second of the last write. Server-owned.",
|
||||
"Application.website": "Website is the applicant's website as submitted.",
|
||||
"ScreenResult.draftReply": "DraftReply is a suggested email reply for staff to edit and send.",
|
||||
"ScreenResult.error": "Error says why a failed screen failed — no AI gateway configured, a gateway\nerror, or a reply that carried no parseable JSON. Absent on success.",
|
||||
"ScreenResult.model": "Model is the LLM the screen ran on.",
|
||||
"ScreenResult.score": "Score is the model's 0..100 fit score, clamped to that range.",
|
||||
"ScreenResult.screenedAt": "ScreenedAt is the unix second the screen finished (0 while pending).",
|
||||
"ScreenResult.status": "Status is the screen's state: pending | done | failed.",
|
||||
"ScreenResult.suggestedCredits": "SuggestedCredits is the recommended credit grant in USD, snapped to the\nnearest allowed rung: 0 | 5000 | 25000 | 50000 | 150000.",
|
||||
"ScreenResult.summary": "Summary is the model's short assessment of the application.",
|
||||
"ScreenResult.tier1Backed": "Tier1Backed is the model's read on tier-1 backing, normalized to\n\"yes\", \"no\" or \"unclear\" (anything it cannot resolve reads \"unclear\").",
|
||||
"StageEvent.at": "At is the unix second of the move.",
|
||||
"StageEvent.by": "By is who moved it: \"system\" for intake and the AI auto-advance, else the\nvalidated staff user id.",
|
||||
"StageEvent.from": "From is the stage moved out of; empty on the intake event that opens the log.",
|
||||
"StageEvent.note": "Note is the free-text comment recorded with the move. Absent when none.",
|
||||
"StageEvent.to": "To is the stage moved into.",
|
||||
"ref.id": "ID is the record to act on, from the path.",
|
||||
"ProgramApplication.company": "Company is the applicant's company name.",
|
||||
"ProgramApplication.companyId": "CompanyID is the CRM Company minted for this lead at intake, so the startup\nalso appears in the org's standard CRM tabs. Empty when that best-effort\nprojection did not run.",
|
||||
"ProgramApplication.contactId": "ContactID is the CRM Contact minted for this lead at intake. Empty when that\nbest-effort projection did not run.",
|
||||
"ProgramApplication.contactName": "ContactName is the person who applied.",
|
||||
"ProgramApplication.createdAt": "CreatedAt is the unix second the application arrived. Server-owned.",
|
||||
"ProgramApplication.email": "Email is the applicant's email — half of the (email, company) key a\nresubmission refreshes instead of duplicating.",
|
||||
"ProgramApplication.events": "Events is the append-only stage-transition log, oldest first.",
|
||||
"ProgramApplication.id": "ID is the server-minted application id (\"appl_\" + 128 random bits).",
|
||||
"ProgramApplication.metadata": "Metadata is the FULL submitted form, every field, including the arrays the\npromoted columns above do not carry (tier1Investors, useCases) and the\ndeterministic tier1Matched list.",
|
||||
"ProgramApplication.reason": "Reason is why the application was rejected, required to reject. Empty\notherwise.",
|
||||
"ProgramApplication.role": "Role is the applicant's role at their company.",
|
||||
"ProgramApplication.screen": "Screen is the AI screen. It runs after intake, so a freshly created\napplication carries a \"pending\" screen.",
|
||||
"ProgramApplication.stage": "Stage is the pipeline stage: applied, screened, qualified, credits-offered,\nonboarded or rejected. Server-owned — it starts at \"applied\" and moves only\nthrough the transition machine.",
|
||||
"ProgramApplication.tier1": "Tier1 is whether the applicant is tier-1 backed, derived deterministically\nat intake from the submitted fund list — independent of the AI screen.",
|
||||
"ProgramApplication.updatedAt": "UpdatedAt is the unix second of the last write. Server-owned.",
|
||||
"ProgramApplication.website": "Website is the applicant's website as submitted.",
|
||||
"ScreenResult.draftReply": "DraftReply is a suggested email reply for staff to edit and send.",
|
||||
"ScreenResult.error": "Error says why a failed screen failed — no AI gateway configured, a gateway\nerror, or a reply that carried no parseable JSON. Absent on success.",
|
||||
"ScreenResult.model": "Model is the LLM the screen ran on.",
|
||||
"ScreenResult.score": "Score is the model's 0..100 fit score, clamped to that range.",
|
||||
"ScreenResult.screenedAt": "ScreenedAt is the unix second the screen finished (0 while pending).",
|
||||
"ScreenResult.status": "Status is the screen's state: pending | done | failed.",
|
||||
"ScreenResult.suggestedCredits": "SuggestedCredits is the recommended credit grant in USD, snapped to the\nnearest allowed rung: 0 | 5000 | 25000 | 50000 | 150000.",
|
||||
"ScreenResult.summary": "Summary is the model's short assessment of the application.",
|
||||
"ScreenResult.tier1Backed": "Tier1Backed is the model's read on tier-1 backing, normalized to\n\"yes\", \"no\" or \"unclear\" (anything it cannot resolve reads \"unclear\").",
|
||||
"StageEvent.at": "At is the unix second of the move.",
|
||||
"StageEvent.by": "By is who moved it: \"system\" for intake and the AI auto-advance, else the\nvalidated staff user id.",
|
||||
"StageEvent.from": "From is the stage moved out of; empty on the intake event that opens the log.",
|
||||
"StageEvent.note": "Note is the free-text comment recorded with the move. Absent when none.",
|
||||
"StageEvent.to": "To is the stage moved into.",
|
||||
"ref.id": "ID is the record to act on, from the path.",
|
||||
},
|
||||
Example: json.RawMessage(`{"id":"appl_1"}`),
|
||||
})
|
||||
@@ -230,40 +230,40 @@ func init() {
|
||||
zip.Describe("PATCH /v1/crm/applications/:id", zip.Doc{
|
||||
Description: "Moves one Startup Program application through the pipeline. The\nmove is recorded on the application's timeline, attributed to the calling\nstaff user: it may advance exactly one stage, go back to any earlier stage,\nreject from any non-rejected stage, or reopen a rejected application to\n`applied`; anything else is refused. Rejecting requires a reason. A note with\nno stage change is still recorded.",
|
||||
Fields: map[string]string{
|
||||
"Application.company": "Company is the applicant's company name.",
|
||||
"Application.companyId": "CompanyID is the CRM Company minted for this lead at intake, so the startup\nalso appears in the org's standard CRM tabs. Empty when that best-effort\nprojection did not run.",
|
||||
"Application.contactId": "ContactID is the CRM Contact minted for this lead at intake. Empty when that\nbest-effort projection did not run.",
|
||||
"Application.contactName": "ContactName is the person who applied.",
|
||||
"Application.createdAt": "CreatedAt is the unix second the application arrived. Server-owned.",
|
||||
"Application.email": "Email is the applicant's email — half of the (email, company) key a\nresubmission refreshes instead of duplicating.",
|
||||
"Application.events": "Events is the append-only stage-transition log, oldest first.",
|
||||
"Application.id": "ID is the server-minted application id (\"appl_\" + 128 random bits).",
|
||||
"Application.metadata": "Metadata is the FULL submitted form, every field, including the arrays the\npromoted columns above do not carry (tier1Investors, useCases) and the\ndeterministic tier1Matched list.",
|
||||
"Application.reason": "Reason is why the application was rejected, required to reject. Empty\notherwise.",
|
||||
"Application.role": "Role is the applicant's role at their company.",
|
||||
"Application.screen": "Screen is the AI screen. It runs after intake, so a freshly created\napplication carries a \"pending\" screen.",
|
||||
"Application.stage": "Stage is the pipeline stage: applied, screened, qualified, credits-offered,\nonboarded or rejected. Server-owned — it starts at \"applied\" and moves only\nthrough the transition machine.",
|
||||
"Application.tier1": "Tier1 is whether the applicant is tier-1 backed, derived deterministically\nat intake from the submitted fund list — independent of the AI screen.",
|
||||
"Application.updatedAt": "UpdatedAt is the unix second of the last write. Server-owned.",
|
||||
"Application.website": "Website is the applicant's website as submitted.",
|
||||
"ScreenResult.draftReply": "DraftReply is a suggested email reply for staff to edit and send.",
|
||||
"ScreenResult.error": "Error says why a failed screen failed — no AI gateway configured, a gateway\nerror, or a reply that carried no parseable JSON. Absent on success.",
|
||||
"ScreenResult.model": "Model is the LLM the screen ran on.",
|
||||
"ScreenResult.score": "Score is the model's 0..100 fit score, clamped to that range.",
|
||||
"ScreenResult.screenedAt": "ScreenedAt is the unix second the screen finished (0 while pending).",
|
||||
"ScreenResult.status": "Status is the screen's state: pending | done | failed.",
|
||||
"ScreenResult.suggestedCredits": "SuggestedCredits is the recommended credit grant in USD, snapped to the\nnearest allowed rung: 0 | 5000 | 25000 | 50000 | 150000.",
|
||||
"ScreenResult.summary": "Summary is the model's short assessment of the application.",
|
||||
"ScreenResult.tier1Backed": "Tier1Backed is the model's read on tier-1 backing, normalized to\n\"yes\", \"no\" or \"unclear\" (anything it cannot resolve reads \"unclear\").",
|
||||
"StageEvent.at": "At is the unix second of the move.",
|
||||
"StageEvent.by": "By is who moved it: \"system\" for intake and the AI auto-advance, else the\nvalidated staff user id.",
|
||||
"StageEvent.from": "From is the stage moved out of; empty on the intake event that opens the log.",
|
||||
"StageEvent.note": "Note is the free-text comment recorded with the move. Absent when none.",
|
||||
"StageEvent.to": "To is the stage moved into.",
|
||||
"patchApplicationIn.id": "ID is the application to move, from the path.",
|
||||
"patchApplicationIn.note": "Note is a free-text comment recorded on the timeline, with or without a\nstage change.",
|
||||
"patchApplicationIn.reason": "Reason records WHY, and is required to reject.",
|
||||
"patchApplicationIn.stage": "Stage is the stage to move to: applied, screened, qualified,\ncredits-offered, onboarded or rejected. Omit to leave the stage alone.",
|
||||
"ProgramApplication.company": "Company is the applicant's company name.",
|
||||
"ProgramApplication.companyId": "CompanyID is the CRM Company minted for this lead at intake, so the startup\nalso appears in the org's standard CRM tabs. Empty when that best-effort\nprojection did not run.",
|
||||
"ProgramApplication.contactId": "ContactID is the CRM Contact minted for this lead at intake. Empty when that\nbest-effort projection did not run.",
|
||||
"ProgramApplication.contactName": "ContactName is the person who applied.",
|
||||
"ProgramApplication.createdAt": "CreatedAt is the unix second the application arrived. Server-owned.",
|
||||
"ProgramApplication.email": "Email is the applicant's email — half of the (email, company) key a\nresubmission refreshes instead of duplicating.",
|
||||
"ProgramApplication.events": "Events is the append-only stage-transition log, oldest first.",
|
||||
"ProgramApplication.id": "ID is the server-minted application id (\"appl_\" + 128 random bits).",
|
||||
"ProgramApplication.metadata": "Metadata is the FULL submitted form, every field, including the arrays the\npromoted columns above do not carry (tier1Investors, useCases) and the\ndeterministic tier1Matched list.",
|
||||
"ProgramApplication.reason": "Reason is why the application was rejected, required to reject. Empty\notherwise.",
|
||||
"ProgramApplication.role": "Role is the applicant's role at their company.",
|
||||
"ProgramApplication.screen": "Screen is the AI screen. It runs after intake, so a freshly created\napplication carries a \"pending\" screen.",
|
||||
"ProgramApplication.stage": "Stage is the pipeline stage: applied, screened, qualified, credits-offered,\nonboarded or rejected. Server-owned — it starts at \"applied\" and moves only\nthrough the transition machine.",
|
||||
"ProgramApplication.tier1": "Tier1 is whether the applicant is tier-1 backed, derived deterministically\nat intake from the submitted fund list — independent of the AI screen.",
|
||||
"ProgramApplication.updatedAt": "UpdatedAt is the unix second of the last write. Server-owned.",
|
||||
"ProgramApplication.website": "Website is the applicant's website as submitted.",
|
||||
"ScreenResult.draftReply": "DraftReply is a suggested email reply for staff to edit and send.",
|
||||
"ScreenResult.error": "Error says why a failed screen failed — no AI gateway configured, a gateway\nerror, or a reply that carried no parseable JSON. Absent on success.",
|
||||
"ScreenResult.model": "Model is the LLM the screen ran on.",
|
||||
"ScreenResult.score": "Score is the model's 0..100 fit score, clamped to that range.",
|
||||
"ScreenResult.screenedAt": "ScreenedAt is the unix second the screen finished (0 while pending).",
|
||||
"ScreenResult.status": "Status is the screen's state: pending | done | failed.",
|
||||
"ScreenResult.suggestedCredits": "SuggestedCredits is the recommended credit grant in USD, snapped to the\nnearest allowed rung: 0 | 5000 | 25000 | 50000 | 150000.",
|
||||
"ScreenResult.summary": "Summary is the model's short assessment of the application.",
|
||||
"ScreenResult.tier1Backed": "Tier1Backed is the model's read on tier-1 backing, normalized to\n\"yes\", \"no\" or \"unclear\" (anything it cannot resolve reads \"unclear\").",
|
||||
"StageEvent.at": "At is the unix second of the move.",
|
||||
"StageEvent.by": "By is who moved it: \"system\" for intake and the AI auto-advance, else the\nvalidated staff user id.",
|
||||
"StageEvent.from": "From is the stage moved out of; empty on the intake event that opens the log.",
|
||||
"StageEvent.note": "Note is the free-text comment recorded with the move. Absent when none.",
|
||||
"StageEvent.to": "To is the stage moved into.",
|
||||
"patchApplicationIn.id": "ID is the application to move, from the path.",
|
||||
"patchApplicationIn.note": "Note is a free-text comment recorded on the timeline, with or without a\nstage change.",
|
||||
"patchApplicationIn.reason": "Reason records WHY, and is required to reject.",
|
||||
"patchApplicationIn.stage": "Stage is the stage to move to: applied, screened, qualified,\ncredits-offered, onboarded or rejected. Omit to leave the stage alone.",
|
||||
},
|
||||
Example: json.RawMessage(`{"id":"appl_1","stage":"rejected","reason":"not a fit this round"}`),
|
||||
})
|
||||
|
||||
@@ -459,6 +459,27 @@ func (o ops) deleteDocType(ctx context.Context, in *docTypeRef) (*noContent, err
|
||||
|
||||
// ---- Roles ----
|
||||
|
||||
// RoleAssignment is one (user, role) grant on the wire.
|
||||
//
|
||||
// It is the engine's Role under a name that says which of the two role-shaped
|
||||
// things it is, and it exists because the fleet publishes ONE schema per name.
|
||||
// iam already publishes a Role: the role ENTITY, carrying a display name, its
|
||||
// members, its domains and the roles it includes. This is the far smaller thing —
|
||||
// the EDGE that joins one user to one role — and the two share nothing but the
|
||||
// word. The weave refuses that collision rather than pick a winner, and it is
|
||||
// right to: a generated SDK binds whichever shape it read last, so a client's
|
||||
// Role would silently mean an entity in one method and a grant in another.
|
||||
//
|
||||
// The engine type stays as it is and is still re-exported by alias.go for in-process
|
||||
// lanes; this is the name the HTTP surface publishes, converted at the handler
|
||||
// boundary, which is the only place the two need to agree.
|
||||
type RoleAssignment struct {
|
||||
// User is the member the role is granted to.
|
||||
User string `json:"user"`
|
||||
// Role is the granted role's name.
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// roleRef addresses one role assignment by the (user, role) pair in the URL.
|
||||
type roleRef struct {
|
||||
// User is the assignee whose grant is being revoked, from the path.
|
||||
@@ -472,7 +493,7 @@ type roleRef struct {
|
||||
// roleList is a page of role assignments.
|
||||
type roleList struct {
|
||||
// Data is every (user, role) assignment in the caller's org.
|
||||
Data []Role `json:"data"`
|
||||
Data []RoleAssignment `json:"data"`
|
||||
}
|
||||
|
||||
// listRoles returns every (user, role) assignment in the caller's org. Roles are
|
||||
@@ -483,7 +504,11 @@ func (o ops) listRoles(ctx context.Context, _ *noInput) (*roleList, error) {
|
||||
if err != nil {
|
||||
return nil, fail(err, "")
|
||||
}
|
||||
return &roleList{Data: rows}, nil
|
||||
out := make([]RoleAssignment, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = RoleAssignment{User: r.User, Role: r.Role}
|
||||
}
|
||||
return &roleList{Data: out}, nil
|
||||
}
|
||||
|
||||
// assignRole grants one user one role in the caller's org — how a member gains
|
||||
@@ -491,12 +516,12 @@ func (o ops) listRoles(ctx context.Context, _ *noInput) (*roleList, error) {
|
||||
// Manager-only. Answers 201.
|
||||
//
|
||||
// Example: {"user": "u_alice", "role": "System Manager"}
|
||||
func (o ops) assignRole(ctx context.Context, in *Role) (*Role, error) {
|
||||
func (o ops) assignRole(ctx context.Context, in *RoleAssignment) (*RoleAssignment, error) {
|
||||
saved, err := o.s.State.eng.AssignRole(ctx, callerOf(ctx), in.User, in.Role)
|
||||
if err != nil {
|
||||
return nil, fail(err, "")
|
||||
}
|
||||
return &saved, nil
|
||||
return &RoleAssignment{User: saved.User, Role: saved.Role}, nil
|
||||
}
|
||||
|
||||
// revokeRole removes one (user, role) grant in the caller's org. Manager-only.
|
||||
|
||||
@@ -81,7 +81,9 @@ func init() {
|
||||
zip.Describe("GET /v1/framework/roles", zip.Doc{
|
||||
Description: "Returns every (user, role) assignment in the caller's org. Roles are\nwhat DocType permissions are written against, so this is the grant table the\npermission calculus resolves a member's rights from.",
|
||||
Fields: map[string]string{
|
||||
"roleList.data": "Data is every (user, role) assignment in the caller's org.",
|
||||
"RoleAssignment.role": "Role is the granted role's name.",
|
||||
"RoleAssignment.user": "User is the member the role is granted to.",
|
||||
"roleList.data": "Data is every (user, role) assignment in the caller's org.",
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/framework/summary", zip.Doc{
|
||||
@@ -123,7 +125,11 @@ func init() {
|
||||
})
|
||||
zip.Describe("POST /v1/framework/roles", zip.Doc{
|
||||
Description: "Grants one user one role in the caller's org — how a member gains\nrights on a DocType, since permissions name roles and never users.\nManager-only. Answers 201.",
|
||||
Example: json.RawMessage(`{"user":"u_alice","role":"System Manager"}`),
|
||||
Fields: map[string]string{
|
||||
"RoleAssignment.role": "Role is the granted role's name.",
|
||||
"RoleAssignment.user": "User is the member the role is granted to.",
|
||||
},
|
||||
Example: json.RawMessage(`{"user":"u_alice","role":"System Manager"}`),
|
||||
})
|
||||
zip.Describe("PUT /v1/framework/:doctype/:name", zip.Doc{
|
||||
Description: "Binds a Service-scoped handler to a route: it adapts a\n`func(*Service[S], *zip.Ctx) error` to the plain `func(*zip.Ctx) error` the\nrouter takes, capturing s. One adapter, so packages write free-function\nhandlers and register them with `app.Get(\"/path\", cloud.Handle(s, myHandler))`.",
|
||||
|
||||
@@ -285,9 +285,26 @@ func init() {
|
||||
"Synced.noOp": "NoOp is true when native was already at that tip.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /git/mirror", zip.Doc{
|
||||
Description: "Registers (Enabled) or removes (!Enabled) one outbound mirror\ntarget on a repo of the CALLER's org, idempotently either way.\n\nIt declares the target and nothing more: the pushing stays with the mirror_out\nreactor on the native push lifecycle, so a mirror that exists is a fact about\nthis repo rather than a job somebody has to keep running. EnsureMirror is the\nsame func the in-process controller exposes, so the URL crossing the plane\npasses the identical validateMirrorTarget gate — https, no userinfo, host on\nthe outbound allowlist — and a remote caller cannot register a push to an\ninternal host that a local one could not.\n\nThe error is returned as it comes: a rejected URL is already an HTTPError(400)\nand survives the crossing whole, while a store failure carries no status and\nlands as the 500 it is. Wrapping both would turn the caller's own mistake into\nour fault.\n\nA named handler, not a closure, so zipdoc can lift this prose into the registry.",
|
||||
Fields: map[string]string{
|
||||
"MirrorIn.enabled": "Enabled registers the target when true and removes it when false.",
|
||||
"MirrorIn.url": "URL is the outbound target to push to.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /git/publish", zip.Doc{
|
||||
Description: "Reconciles a project's canonical repo to the project's published\nvisibility: it provisions the repo on first publish and thereafter flips only\nthe public bit, then keeps the GitHub replica's visibility in step.\nIdempotent, so projects can fire it on every create, visibility change and\nmoderation event. The org is the CALLER's plane identity, never the argument —\na caller that could name the org would be publishing into another tenant's\nrepos — and an anonymous caller is refused. A named handler, not a closure, so\nzipdoc can lift this prose into the registry.",
|
||||
})
|
||||
zip.Describe("POST /git/status", zip.Doc{
|
||||
Description: "Reports which of the named repos the CALLER's org has imported and\nwhich a prior inbound sync left in conflict.\n\nThe app that DRAWS the repo list is integrations (it has the provider's\ncatalogue of what could be imported); the app that knows what WAS is this one.\nIn a split fleet the in-process importer is nil over there, so the list\nrendered every repo as never-imported — a wrong answer delivered confidently,\nwhich is worse than the import failure the same split caused, because nothing\nerrored.\n\nThe reply is a SLICE, not a map: a map cannot cross this wire, so each row\ncarries the name it answers for. A name git holds nothing under is ABSENT\nrather than a false row — the caller reads absence as not-imported, which is\nthe same value the in-process leg's zero entry yields, so neither leg can be\ntold from the other by its result.\n\nIt calls the in-process implementation directly rather than\ncloud.GitRepoStatuses: the package func dispatches to whatever is registered,\nand in THIS process that resolution would come back around through the plane\nto this same handler.\n\nA named handler, not a closure, so zipdoc can lift this prose into the registry.",
|
||||
Fields: map[string]string{
|
||||
"RepoStatus.conflict": "Conflict is true when a branch diverged on a prior inbound sync and native\nwas preserved.",
|
||||
"RepoStatus.imported": "Imported is true when a native repo exists for this name.",
|
||||
"RepoStatus.lastSyncedAt": "LastSyncedAt is unix seconds of the last import/sync; 0 means never.",
|
||||
"StatusIn.names": "Names are the repo names to report on.",
|
||||
"StatusIn.project": "Project is the sub-scope; empty means the org's default store.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/git/:org/:project/:repo/git-receive-pack", zip.Doc{
|
||||
Description: "Binds a Service-scoped handler to a route: it adapts a\n`func(*Service[S], *zip.Ctx) error` to the plain `func(*zip.Ctx) error` the\nrouter takes, capturing s. One adapter, so packages write free-function\nhandlers and register them with `app.Get(\"/path\", cloud.Handle(s, myHandler))`.",
|
||||
})
|
||||
|
||||
@@ -612,6 +612,27 @@ const linkFlow = "\n\nThis is one leg of a three-leg flow, and the legs are not
|
||||
// flows seal what they obtain into the org's KMS namespace, and the callback seals
|
||||
// before it writes anything at all.
|
||||
func init() {
|
||||
// ── install entry point ──────────────────────────────────────────────────
|
||||
openapi.Describe("/v1/integrations/slack/install", http.MethodGet,
|
||||
"Install the Hanzo app into a Slack workspace",
|
||||
"The address behind Slack's \"Add to Slack\" and Marketplace Install buttons. It answers a "+
|
||||
"302 to Slack's own consent screen and does nothing else — it is a redirector by "+
|
||||
"design.\n\n"+
|
||||
"It exists because Slack refuses a slack.com URL in that field and requires one of ours "+
|
||||
"that redirects there, which makes the field an ATTRIBUTION hook: routing the click "+
|
||||
"through our own address is what lets an install be counted, and always answering the "+
|
||||
"redirect is what keeps the counter from becoming a detour that never reaches consent. "+
|
||||
"The destination is the same consent URL every time, built from the same scopes the "+
|
||||
"console's Connect button asks for, so a workspace is asked to grant one thing however "+
|
||||
"the install began.\n\n"+
|
||||
"It is PUBLIC and carries no principal, because whoever clicks Install in Slack's "+
|
||||
"directory has no Hanzo session yet. It binds no org either, and that is deliberate "+
|
||||
"rather than missing: the org is resolved at the shared provider callback, from the "+
|
||||
"signed state a console connect minted or from the workspace's existing connection. "+
|
||||
"Minting an org for an anonymous click is the one thing that would break tenant "+
|
||||
"isolation, so an install begun here finishes under exactly the rules every other "+
|
||||
"install obeys.")
|
||||
|
||||
// ── inbound platform webhooks ────────────────────────────────────────────
|
||||
openapi.Describe("/v1/integrations/slack/events", http.MethodPost,
|
||||
"Slack Events API webhook",
|
||||
|
||||
@@ -178,9 +178,19 @@ func init() {
|
||||
"projectsUpdate.visibility": "Visibility flips an existing project between \"public\" and \"private\". Same\nONE rule as at create: public is free, private needs a paid plan.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /projects/ownership", zip.Doc{
|
||||
Description: "Answers whether the CALLER's org owns the named project and\nwhether some other org does.\n\nThe org is the caller's plane identity and never the argument — plane.OwnerIn\nhas no org field, deliberately, because the org is precisely what the answer is\nrelative to: a caller able to state it could ask the question about somebody\nelse and act on the answer. An anonymous caller is refused rather than defaulted.\n\nBoth false is a REAL answer: nobody has registered this identifier, so it is a\nfree-form within-org label and the boundary keeps it. That third outcome is why\nthe reply carries two booleans instead of one verdict — collapsing \"nobody owns\nit\" into either \"mine\" or \"another's\" would respectively open the guard or break\nevery subsystem that uses a free-form project label.",
|
||||
Fields: map[string]string{
|
||||
"Ownership.mine": "Mine is true when the caller's own org owns a project with this id/slug.",
|
||||
"Ownership.other": "Other is true when some org OTHER than the caller's owns one.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /projects/resolve-key", zip.Doc{
|
||||
Description: "Answers which (org, project) a key names. Not-found is\n`Found:false`, never an error: the door turns that into an honest refusal, and\nan error into a 5xx. Collapsing them would refuse every live site's beacons\nduring a transient failure of this app.",
|
||||
})
|
||||
zip.Describe("POST /sites/live", zip.Doc{
|
||||
Description: "Answers the cross-org directory read for the process that\nassembles the catalog, which is never this one.\n\nLiveSites returns nil when this package is unmounted, on the reasoning that a\ndeployment which hosts nothing is not a fault. That reads correctly here — the\nprocess that owns the store is the one answering — and read WRONG in the\ncatalog process, where nil meant \"ask somewhere else\" and was silently\npublished as \"nothing is live\". Every demo URL, the whole `site` kind, and the\ntemplate lane's deployed starters left the corpus without an error anywhere.\n\nNo org, on purpose, exactly like the resolve above. This is the one cross-org\nread in the package and the visibility rule that makes it safe lives in its\nquery, not in its caller.",
|
||||
})
|
||||
zip.Describe("POST /sites/resolve", zip.Doc{
|
||||
Description: "Answers the multi-tenant product URL (<slug>.hanzo.app) and\nbound custom domains. Not-found is `Found:false`, never an error: the edge\nturns that into an honest 404, and an error into a 503. Collapsing the two\nwould serve 404s for real live sites during a transient failure.",
|
||||
})
|
||||
|
||||
@@ -42,6 +42,22 @@ func init() {
|
||||
},
|
||||
Example: json.RawMessage(`{"id":"sync_1","direction":"pull"}`),
|
||||
})
|
||||
zip.Describe("POST /sync/run", zip.Doc{
|
||||
Description: "Reconciles every sync of the CALLER's org whose source matches the\nevent, answering how many changed and how many were skipped.\n\nThe org is the caller's plane identity and never the argument — plane.SyncIn has\nno org field, deliberately, because a trigger able to state the org could\nreconcile another tenant's repositories. Anonymous is refused rather than\ndefaulted: an event arriving with no principal must fail, not sync somebody's\nrepos.\n\nIt calls reconcileEvent, never cloud.Sync. cloud.Sync now falls through to THIS\nop when the local one is nil, so a process serving it that dispatched through\nit would dial its own socket and answer itself, forever.\n\nA named handler, not a closure, so zipdoc can lift this prose into the registry.",
|
||||
Fields: map[string]string{
|
||||
"SyncIn.actor": "Actor is who made the upstream push; the engine's loop guard compares it to\nthe sync's own actor.",
|
||||
"SyncIn.hop": "Hop is the chained-propagation depth, bounded by the engine's hop limit.",
|
||||
"SyncIn.kind": "Kind is the sync kind, e.g. \"git\".",
|
||||
"SyncIn.locator": "Locator is the source repo locator — a clone URL, or \"<owner>/<repo>\".",
|
||||
"SyncIn.manual": "Manual marks a /run or an initial reconcile rather than a specific push.",
|
||||
"SyncIn.provider": "Provider is the endpoint the event came from: github | gitlab | hanzo-git.",
|
||||
"SyncIn.ref": "Ref is the FULL ref that moved.",
|
||||
"SyncIn.repo": "Repo is the short repo name.",
|
||||
"SyncIn.token": "Token is an OPTIONAL short-lived credential the trigger already minted. It\nrides the internal socket only and is never logged.",
|
||||
"SyncRan.ran": "Ran is the number of syncs that reconciled a change.",
|
||||
"SyncRan.skipped": "Skipped is the number resolved but skipped — loop guard, idempotent, or\ndirection off.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/sync", zip.Doc{
|
||||
Description: "Create declares a sync between two endpoints and returns it. It is an UPSERT:\nre-declaring the same source and target updates that link rather than piling up\nduplicates, so a console that re-submits is safe. The org comes from the validated\nprincipal, never from the request, so a sync can only ever bind endpoints inside\nthe caller's own org. A git source must be an https clone URL on the provider's own\nhost with no embedded credentials; a target left empty is derived as a native\nrepository named after the source. With run=true the first reconcile is queued in\nthe background, so a large initial import never blocks this response.",
|
||||
Fields: map[string]string{
|
||||
|
||||
+11
-2
@@ -56,7 +56,7 @@ func init() {
|
||||
},
|
||||
})
|
||||
zip.Describe("GET /v1/team/transactor/api/v1/statistics", zip.Doc{
|
||||
Description: "Statistics returns the transactor's live sessions for the workspace the\ncaller's token names — the endpoint the front's workspace switcher and server\npanel poll on the transactor base. The token is verified exactly like the\nWebSocket upgrade is, and activeSessions carries ONLY that token's own\nworkspace, never another tenant's sessions. An invalid or expired token is\n401.",
|
||||
Description: "Statistics returns the transactor's live sessions for the workspace the caller's\ncredential names — the endpoint the front's workspace switcher and server panel\npoll on the transactor base. `token` carries the same two lanes the socket's path\nsegment does: a workspace UUID names the workspace and is authorized against the\nmembership rows, an HS256 workspace token names it in its signed claims.\nactiveSessions carries ONLY that one workspace, never another tenant's sessions.\nAn unverifiable credential, or one the caller is no member under, is 401.",
|
||||
Fields: map[string]string{
|
||||
"statsIn.token": "Token is the workspace token minted by selectWorkspace.",
|
||||
"statsOut.admin": "Admin is the upstream service's server-panel flag, always false here.",
|
||||
@@ -68,7 +68,7 @@ func init() {
|
||||
Example: json.RawMessage(`{"token":"eyJhbGciOiJIUzI1NiJ9…"}`),
|
||||
})
|
||||
zip.Describe("GET /v1/team/transactor/statistics", zip.Doc{
|
||||
Description: "Statistics returns the transactor's live sessions for the workspace the\ncaller's token names — the endpoint the front's workspace switcher and server\npanel poll on the transactor base. The token is verified exactly like the\nWebSocket upgrade is, and activeSessions carries ONLY that token's own\nworkspace, never another tenant's sessions. An invalid or expired token is\n401.",
|
||||
Description: "Statistics returns the transactor's live sessions for the workspace the caller's\ncredential names — the endpoint the front's workspace switcher and server panel\npoll on the transactor base. `token` carries the same two lanes the socket's path\nsegment does: a workspace UUID names the workspace and is authorized against the\nmembership rows, an HS256 workspace token names it in its signed claims.\nactiveSessions carries ONLY that one workspace, never another tenant's sessions.\nAn unverifiable credential, or one the caller is no member under, is 401.",
|
||||
Fields: map[string]string{
|
||||
"statsIn.token": "Token is the workspace token minted by selectWorkspace.",
|
||||
"statsOut.admin": "Admin is the upstream service's server-panel flag, always false here.",
|
||||
@@ -93,6 +93,15 @@ func init() {
|
||||
},
|
||||
Example: json.RawMessage(`{"documentId":"6579…|tracker:class:Issue|issue-1|description","method":"getContent","payload":{"source":"issue-1-description-1730000000000"}}`),
|
||||
})
|
||||
zip.Describe("POST /team/member", zip.Doc{
|
||||
Fields: map[string]string{
|
||||
"Member.account": "Account is the team AccountUuid the subject resolved to — the identity the\nasking process attributes the person by, so it never derives one itself.",
|
||||
"Member.member": "Member reports whether the subject holds a row in that workspace.",
|
||||
"Member.role": "Role is the workspace role on that row (owner | admin | member | guest).",
|
||||
"MemberIn.subject": "Subject is the IAM subject, NOT a team account id. team owns the join from\none to the other — it is the join that created the rows — so a peer that\ncomputed its own would be a second derivation of the same address, which is\nhow two layers end up naming different accounts for one person.",
|
||||
"MemberIn.workspace": "Workspace is the workspace uuid, scoped to the caller's org on the read.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/team/bots/sync", zip.Doc{
|
||||
Description: "SyncBots re-projects the caller org's agents as workspace members into EVERY\nworkspace of the org, and removes the ones whose agent is gone. It is\nidempotent, and admin only: mutating a workspace's roster requires the\ngateway-minted admin flag, which a client can never forge. It answers how many\nroster entries the reconcile touched.",
|
||||
Fields: map[string]string{
|
||||
|
||||
@@ -87,6 +87,22 @@ func init() {
|
||||
},
|
||||
Example: json.RawMessage(`{"key":"ENG","num":14,"status":"in_progress","assignee":"z"}`),
|
||||
})
|
||||
zip.Describe("POST /tracker/upsert", zip.Doc{
|
||||
Description: "Mirrors one external work item into the CALLER's org — creating the\nrow, or updating the one already carrying that ExtRef — and reports which it did\nplus the tracker identity the item is now known by.\n\nThe org is the caller's plane identity and never the argument — plane.IssueIn has\nno org field, deliberately, because a feeder able to state the org could file\ninto another tenant's tracker. Anonymous is refused rather than defaulted: an\nitem arriving with no principal must fail, not land on somebody's board.\n\nIt calls upsertIssue, never cloud.UpsertIssue. cloud.UpsertIssue now falls\nthrough to THIS op when the local sink is nil, so a process serving it that went\nback through it would dial its own socket and ask itself, forever.\n\nA named handler, not a closure, so zipdoc can lift this prose into the registry.",
|
||||
Fields: map[string]string{
|
||||
"IssueIn.extRef": "ExtRef is the external anchor AND the idempotency key, e.g.\n\"github:owner/repo#123\".",
|
||||
"IssueIn.key": "Key is the tracker team the item files under, e.g. \"GH\"; ensured on first use.",
|
||||
"IssueIn.kind": "Kind is what it is — \"issue\" | \"pr\".",
|
||||
"IssueIn.project": "Project is the IAM project scope; empty means the org's default store.",
|
||||
"IssueIn.repo": "Repo is the git repo the item belongs to — the per-repo filter discriminator.",
|
||||
"IssueIn.source": "Source is which surface opened it, e.g. \"git\".",
|
||||
"IssueIn.state": "State is the upstream open/closed state; the tracker maps it to a column.",
|
||||
"IssueIn.teamName": "TeamName is the display name used when that team is first created.",
|
||||
"IssueUpserted.created": "Created distinguishes a new row from an update.",
|
||||
"IssueUpserted.identifier": "Identifier is KEY-<number>.",
|
||||
"IssueUpserted.number": "Number is the tracker's own item number.",
|
||||
},
|
||||
})
|
||||
zip.Describe("POST /v1/tracker/projects", zip.Doc{
|
||||
Description: "Binds a Service-scoped handler to a route: it adapts a\n`func(*Service[S], *zip.Ctx) error` to the plain `func(*zip.Ctx) error` the\nrouter takes, capturing s. One adapter, so packages write free-function\nhandlers and register them with `app.Get(\"/path\", cloud.Handle(s, myHandler))`.",
|
||||
})
|
||||
|
||||
+11
-1
@@ -236,7 +236,17 @@ var Apps = []App{
|
||||
// aliases under it are retired — and /v1/errors, /v1/insights/events and
|
||||
// /v1/insights/health are GET lenses. /v1/tracker is NOT here and never was:
|
||||
// the tracker product owns that name (its row is above, and it wins the prefix).
|
||||
{Name: "analytics", Prefixes: []string{"/v1/analytics", "/v1/errors", "/v1/event", "/v1/insights/events", "/v1/insights/health"}},
|
||||
//
|
||||
// "/v1/event.js" is its OWN prefix and cannot be folded into "/v1/event": a
|
||||
// prefix owns segments, and ".js" is part of this one's single segment rather
|
||||
// than a child of it, so the ingest door's claim stops short of the tag. It is
|
||||
// the hosted tag — the script every instrumented surface loads before it can
|
||||
// emit a single beacon — and unclaimed it fell to ai's bare "/v1", which answers
|
||||
// a 404 that reads to a browser as a broken script tag rather than as a routing
|
||||
// mistake. That was invisible for as long as plugin/analytics/openapi.json went
|
||||
// unregenerated: the path was in the router and not in the artifact this table
|
||||
// is checked against, so the check had nothing to disagree with.
|
||||
{Name: "analytics", Prefixes: []string{"/v1/analytics", "/v1/errors", "/v1/event", "/v1/event.js", "/v1/insights/events", "/v1/insights/health"}},
|
||||
{Name: "git", Prefixes: []string{"/explore", "/git", "/v1/git"}},
|
||||
{Name: "sync", Prefixes: []string{"/v1/sync"}},
|
||||
{Name: "visor", Prefixes: []string{"/v1/clusters", "/v1/compute/bots", "/v1/compute/regions", "/v1/compute/sizes", "/v1/fleet", "/v1/gpus", "/v1/k8s/clusters", "/v1/k8s/nodes", "/v1/machines"}},
|
||||
|
||||
+6940
-3466
File diff suppressed because it is too large
Load Diff
+7
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"paths": 1696,
|
||||
"operations": 2349,
|
||||
"paths": 1735,
|
||||
"operations": 2474,
|
||||
"products": {
|
||||
"admin": 86,
|
||||
"ads": 7,
|
||||
@@ -20,7 +20,7 @@
|
||||
"balancers": 4,
|
||||
"base": 1,
|
||||
"benchmark": 6,
|
||||
"billing": 35,
|
||||
"billing": 40,
|
||||
"blueprint": 3,
|
||||
"books": 25,
|
||||
"bot": 11,
|
||||
@@ -36,7 +36,8 @@
|
||||
"clusters": 6,
|
||||
"code": 7,
|
||||
"collections": 14,
|
||||
"commerce": 8,
|
||||
"commands": 1,
|
||||
"commerce": 123,
|
||||
"company": 22,
|
||||
"completions": 1,
|
||||
"compliance": 17,
|
||||
@@ -94,7 +95,7 @@
|
||||
"ingress": 18,
|
||||
"insights": 2,
|
||||
"install-patch": 1,
|
||||
"integrations": 32,
|
||||
"integrations": 33,
|
||||
"k8s": 5,
|
||||
"kb": 9,
|
||||
"keys": 3,
|
||||
@@ -118,7 +119,7 @@
|
||||
"mq": 15,
|
||||
"networks": 3,
|
||||
"notify": 4,
|
||||
"o11y": 363,
|
||||
"o11y": 364,
|
||||
"oracles": 1,
|
||||
"org": 14,
|
||||
"orgs": 3,
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"/v1/keys": {
|
||||
"delete": {
|
||||
"operationId": "delete_v1_keys",
|
||||
"operationId": "v1.delete_keys",
|
||||
"summary": "Revokes the caller's own API key of the requested class.",
|
||||
"description": "Revokes the caller's own API key of the requested class. The class is\nthe same field mint takes — `?type=publishable`, defaulting to secret — so\nrevoking the key that ships in a browser bundle does not sign its holder out of\ntheir own API: the other key keeps working.\n\nRevoking is how a key is replaced when it does not need replacing; minting the\nsame class again rotates it in one step. IAM drops the credential immediately,\nbut the gateway caches keys for a few minutes, so a request that beat the cache\nexpiry may still be served.\n\nFor callers written against the older shape, the class is also accepted in a JSON\nrequest body, read only when `?type=` is absent.",
|
||||
"tags": [
|
||||
@@ -184,7 +184,7 @@
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"operationId": "post_v1_keys",
|
||||
"operationId": "v1.post_keys",
|
||||
"summary": "Creates — or rotates — the caller's API key of the requested type and returns it ONCE.",
|
||||
"description": "Creates — or rotates — the caller's API key of the requested type and\nreturns it ONCE. A real IAM failure surfaces as 502, never a fabricated key.\n\nRotating is what creating means here: a user holds one key per type, so the\nendpoint is idempotent by (caller, type) and the superseded credential stops\nworking. Two live secrets for one user would make \"revoke my key\" a lie.",
|
||||
"tags": [
|
||||
@@ -219,9 +219,9 @@
|
||||
},
|
||||
"/v1/orgs": {
|
||||
"post": {
|
||||
"operationId": "post_v1_orgs",
|
||||
"operationId": "v1.post_orgs",
|
||||
"summary": "Onboard creates the caller's organization.",
|
||||
"description": "Onboard creates the caller's organization. Two flows, keyed on whether the caller\nalready has a home org (mirrors app/onboard/route.ts):\n\n - FIRST-RUN (no owner): create + MOVE the user in as admin, so their next JWT\n carries the new owner and the cloud scopes everything to it.\n - ADDITIONAL (owner set): create the org but do NOT move the user — a move\n changes their IAM owner (stripping a SuperAdmin's status + orphaning their\n current org). They reach the new org via the OrgSwitcher, which re-scopes\n X-Org-Id without touching IAM membership. A personal-org request from someone\n who already has an org is meaningless → 409.",
|
||||
"description": "Onboard creates the caller's organization. Two flows, keyed on whether the caller\nalready has a home org (mirrors app/onboard/route.ts):\n\n - FIRST-RUN (no home org): create + MOVE the user in as admin, so their next\n JWT carries the new owner and the cloud scopes everything to it. This is the\n path a fresh OAuth sign-up takes, from the sign-up application's org.\n - ADDITIONAL (owner set): create the org but do NOT move the user — a move\n changes their IAM owner (stripping a SuperAdmin's status + orphaning their\n current org). They reach the new org via the OrgSwitcher, which re-scopes\n X-Org-Id without touching IAM membership. A personal-org request from someone\n who already has an org is meaningless → 409.",
|
||||
"tags": [
|
||||
"orgs"
|
||||
],
|
||||
@@ -371,6 +371,14 @@
|
||||
},
|
||||
"onboardResp": {
|
||||
"properties": {
|
||||
"accessKey": {
|
||||
"description": "AccessKey is the identifier of the org-scoped credential provisioning minted\nwith the organization. Present on a first run that actually minted one.",
|
||||
"type": "string"
|
||||
},
|
||||
"accessSecret": {
|
||||
"description": "AccessSecret is that credential's confidential half, returned ONCE — on the\nresponse that mints it and never again. IAM keeps only its argon2id digest\nand blanks the plaintext, so this is the single moment it exists in a form\nits owner can read; a replay of the same provision re-reveals nothing.",
|
||||
"type": "string"
|
||||
},
|
||||
"additional": {
|
||||
"description": "Additional is true when the caller already had an organization and this one\nwas created WITHOUT moving them into it — they reach it via the org switcher.",
|
||||
"type": "boolean"
|
||||
|
||||
@@ -5294,6 +5294,46 @@
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"crawlDocument": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"markdown": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"crawlRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"crawlResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/crawlDocument"
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@
|
||||
"post": {
|
||||
"operationId": "post_v1_event",
|
||||
"summary": "Capture product events into your org's warehouse",
|
||||
"description": "Stores pageviews, browser errors, identifies and custom commerce events as rows in the caller's own tenant, and answers a receipt {accepted, dropped} that always totals what was sent — a beacon is never silently discarded.\n\nTHE STATUS SAYS WHETHER ANYTHING LANDED, so a green check can never mean an empty warehouse. 200 means at least one event was stored (or that nothing was sent), and a nonzero `dropped` beside a nonzero `accepted` is a PARTIAL batch, never a failed one — a batch is not refused whole for its worst element. If NOTHING was stored the request is an error, and it names the one thing that fixes it: 401 `ingest_key_required` when every event was refused for want of a credential (the same events land with a key), and 400 `unroutable_events` when the caller HAD capability and the body still named nothing storable.\n\nONE door for every wire a Hanzo surface emits, dispatched by the SHAPE of the body and never by a second path: a bare event object, a bare array of them, the {batch:[…]} / {events:[…]} envelope, the team console's snake_case array, and the PostHog wire (spelled `distinct_id`/`api_key`, which the canonical wire never uses). BATCH IS A BODY, NOT A PATH — there is no /v1/event/batch, because an array already is one.\n\nWHAT THE CALLER PRESENTS DECIDES WHAT IT MAY WRITE, and the door itself grants nothing. A validated bearer or an org API key writes the full event at full fidelity. A PUBLISHABLE key (pk-, on Authorization: Bearer, x-hanzo-ingest-key, or ?ingest_key= for navigator.sendBeacon, which cannot set headers) does the same, and is the credential a browser bundle ships: it is deliberately NOT a secret, it resolves WHICH tenant a beacon belongs to and nothing more. A pk- never authenticates and can READ NOTHING — not this org's errors, not a lens, not any other route on this API — so a leaked one lets a stranger write into your stream, and never lets one read out of it. Reading these rows back always takes a real bearer. A Hanzo Team workspace token resolves its org at REDUCED capability: the signed account names the person, so a `distinctId` in the body cannot pin events on a colleague.\n\nNO CREDENTIAL IS ALSO ADMITTED, and that is the point — a logged-out visitor has none. Such a write is PROJECTED: filed under the reserved `$public` tenant, narrowed to what the SERVER can name — pageviews and errors, plus the closed autocapture vocabulary ($click, $input, $change, $submit, $view) — where EVERY one of those names is resolved through a server-owned table and stored as that table's value, so the name on the wire is never the name in the row. Stripped, too, to the fields the projection names, so revenue, personId, groupId and every property but the element annotation cannot reach a row — and an exception is carried only on an error, never on an interaction, so a click cannot ship a stack trace into a row's attributes. ITS IDENTITY IS NAMESPACED for the same reason the name is: nobody signed for it, so a `distinctId` off the wire is stored under a reserved `$anon:` prefix that no identified subject carries — an anonymous visitor still counts as one visitor, and still cannot be joined to a person the org knows. Everything refused is counted in `dropped`. On a published-site host the same projection applies with that site's org as the tenant. But a credential that IS presented and does NOT resolve is 403, never quietly downgraded: filing a misconfigured key's events under $public would hide them in a partition their owner cannot read.\n\nThe anonymous lane alone is bounded: 413 over 64 KiB, 400 over 50 events, 429 on the per-client-IP and per-peer caps, and a DNT:1 or Sec-GPC:1 request stores nothing and says so in the receipt. Two stored values carry their own bounds on top, because a request cap does not bound one value: an element annotation over 2 KiB (or a trail over 32 steps) and an exception class over 256 bytes are dropped from the row, which still lands. Where a deployment switches anonymous capture off, a credential-less write is 403 instead. Authenticated bodies are offered to the observability plane first, which claims LLM-observability ingestion batches and declines everything else.",
|
||||
"description": "Stores pageviews, browser errors, identifies and custom commerce events as rows in the caller's own tenant, and answers a receipt {accepted, dropped} that always totals what was sent — a beacon is never silently discarded.\n\nTHE STATUS SAYS WHETHER ANYTHING LANDED, so a green check can never mean an empty warehouse. 200 means at least one event was stored (or that nothing was sent), and a nonzero `dropped` beside a nonzero `accepted` is a PARTIAL batch, never a failed one — a batch is not refused whole for its worst element. If NOTHING was stored the request is an error, and it names the one thing that fixes it: 401 `ingest_key_required` when every event was refused for want of a credential (the same events land with a key), and 400 `unroutable_events` when the caller HAD capability and the body still named nothing storable.\n\nONE door for every wire a Hanzo surface emits, dispatched by the SHAPE of the body and never by a second path: a bare event object, a bare array of them, the {batch:[…]} / {events:[…]} envelope, the team console's snake_case array, and the PostHog wire (spelled `distinct_id`/`api_key`, which the canonical wire never uses). BATCH IS A BODY, NOT A PATH — there is no /v1/event/batch, because an array already is one.\n\nWHAT THE CALLER PRESENTS DECIDES WHAT IT MAY WRITE, and the door itself grants nothing. A validated bearer or an org API key writes the full event at full fidelity. A PUBLISHABLE key (pk-, on Authorization: Bearer, x-hanzo-ingest-key, or ?ingest_key= for navigator.sendBeacon, which cannot set headers) does the same, and is the credential a browser bundle ships: it is deliberately NOT a secret, it resolves WHICH tenant a beacon belongs to and nothing more. A pk- never authenticates and can READ NOTHING — not this org's errors, not a lens, not any other route on this API — so a leaked one lets a stranger write into your stream, and never lets one read out of it. Reading these rows back always takes a real bearer. A Hanzo Team workspace token resolves its org at REDUCED capability: the signed account names the person, so a `distinctId` in the body cannot pin events on a colleague.\n\nNO CREDENTIAL IS REFUSED: a write the server cannot attribute to a project is 401 `ingest_key_required`, and a credential that IS presented but resolves to no project is 403 `ingest_key_unknown`. Nothing is filed under a shared tenant — events nobody can read are worse than events nobody sent, because the caller is told it succeeded. A browser bundle therefore always ships a pk-, which is what /v1/event.js takes.\n\nA REDUCED principal — a Hanzo Team workspace token — writes through the PROJECTION into its own org: narrowed to what the SERVER can name (pageviews and errors, plus the closed autocapture vocabulary $click, $input, $change, $submit, $view), where every one of those names is resolved through a server-owned table and stored as that table's value, so the name on the wire is never the name in the row. Stripped, too, to the fields the projection names, so revenue, personId, groupId and every property but the element annotation cannot reach a row — and an exception is carried only on an error, never on an interaction, so a click cannot ship a stack trace into a row's attributes. It does NOT name the person: the signed account is the identity, so a `distinctId` in the body cannot pin events on a colleague. Everything refused is counted in `dropped`.\n\nThe projected lane alone is bounded: 413 over 64 KiB, 400 over 50 events, 429 on the per-client-IP and per-peer caps, and a DNT:1 or Sec-GPC:1 request stores nothing and says so in the receipt. Two stored values carry their own bounds on top, because a request cap does not bound one value: an element annotation over 2 KiB (or a trail over 32 steps) and an exception class over 256 bytes are dropped from the row, which still lands. Authenticated bodies are offered to the observability plane first, which claims LLM-observability ingestion batches and declines everything else.",
|
||||
"tags": [
|
||||
"event"
|
||||
],
|
||||
@@ -294,6 +294,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/event.js": {
|
||||
"get": {
|
||||
"operationId": "get_v1_event.js",
|
||||
"summary": "The Hanzo event tag — the one-line install for a surface with no bundler",
|
||||
"description": "Serves the browser tag that autocaptures pageviews (initial and SPA) and uncaught errors onto the canonical wire at POST /v1/event.\n\nInstall is one line, and it is the same line for a Hanzo property and for a customer's own page:\n\n \u003cscript defer src=\"https://api.hanzo.ai/v1/event.js\" data-key=\"pk-…\"\u003e\u003c/script\u003e\n\n`data-key` is the publishable key the project mints; `data-product` optionally names the emitting surface. The key may also ride the src as `?key=` for a host that strips data attributes.\n\nWITHOUT A KEY THE TAG SENDS NOTHING. A keyless beacon is accepted 200 into $public, a reserved tenant the owning org cannot read — so silence is the honest failure, and the tag picks it rather than reporting success into a tenant nobody reads.",
|
||||
"responses": {
|
||||
"2XX": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/javascript": {
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/event/{project}/envelope": {
|
||||
"post": {
|
||||
"operationId": "post_v1_event_by_project_envelope",
|
||||
|
||||
+12
-12
@@ -23,7 +23,7 @@
|
||||
"get": {
|
||||
"operationId": "get_v1_billing_balance",
|
||||
"summary": "Prepaid credit the caller's org can still spend",
|
||||
"description": "Answers the spendable prepaid balance of the wallet this caller bills from \u2014 the same wallet the AI prepaid gate reads before admitting a paid request, the edge meter debits, and a top-up credits.\n\nThe wallet is an ADDRESS, not an org: `account` echoes the key resolved within the ledger \u2014 the org's shared pool for a tenant org, a personal account for a member of the shared signup org. The echo is the point. A browser could only GUESS its own payer by decoding its own token, and a guess that disagrees with the server is how money lands in an account the gate never reads.\n\n`balance`, `holds` and `available` are whole USD cents, ROUNDED from the ledger's exact 18-decimal value. On the co-resident ledger `holds` is 0 and `available` equals `balance`: the gate's reservations live in its own pod and are never posted, so the settled balance IS the spendable one.\n\nThe ledger is the caller's own org, taken from the VALIDATED IAM owner claim and never from a client header. No validated principal is 401 \u2014 with one exception, the trusted in-process service token the AI gate itself presents, which reads the gateway-pinned org and nothing it could name. A balance that cannot be READ is 502, never 0: unknown is not broke.",
|
||||
"description": "Answers the spendable prepaid balance of the wallet this caller bills from — the same wallet the AI prepaid gate reads before admitting a paid request, the edge meter debits, and a top-up credits.\n\nThe wallet is an ADDRESS, not an org: `account` echoes the key resolved within the ledger — the org's shared pool for a tenant org, a personal account for a member of the shared signup org. The echo is the point. A browser could only GUESS its own payer by decoding its own token, and a guess that disagrees with the server is how money lands in an account the gate never reads.\n\n`balance`, `holds` and `available` are whole USD cents, ROUNDED from the ledger's exact 18-decimal value. On the co-resident ledger `holds` is 0 and `available` equals `balance`: the gate's reservations live in its own pod and are never posted, so the settled balance IS the spendable one.\n\nThe ledger is the caller's own org, taken from the VALIDATED IAM owner claim and never from a client header. No validated principal is 401 — with one exception, the trusted in-process service token the AI gate itself presents, which reads the gateway-pinned org and nothing it could name. A balance that cannot be READ is 502, never 0: unknown is not broke.",
|
||||
"tags": [
|
||||
"billing"
|
||||
]
|
||||
@@ -33,7 +33,7 @@
|
||||
"post": {
|
||||
"operationId": "post_v1_billing_gpu_charge",
|
||||
"summary": "Debit the caller's org prepaid balance for a GPU",
|
||||
"description": "Records a gpu-tagged debit against the caller's own org and answers 201 with the transaction id and the prepaid balance left. This is the ONE endpoint on the customer billing surface that moves an org's ledger.\n\n`requestId` IS THE IDEMPOTENCY KEY. Two posts carrying the same one are ONE debit: the ledger recognizes the ref inside the same transaction as the insert, so the replay moves no money and answers the ORIGINAL transaction id \u2014 a retry, a proxy replay and a double-clicked launch button all cost one GPU. Send it. OMITTED, the debit takes a fresh ref and is additive, which is the same rule every other write on this ledger states for a missing key: without one there is nothing to recognize a repeat by, and two posts are two charges.\n\nTHE PAYER IS NOT A FIELD. Every billing-subject key in the body \u2014 `user`, `userId`, `customerId` \u2014 is ignored and the wallet is resolved server-side from the caller's own validated org, so a forged body can never charge another tenant. `amountCents`, `currency`, `requestId`, `notes` and `tag` are the request, and `tag` is FORCED into the gpu bucket so this can never mint a credit-eligible debit.\n\nTwo gates, both fail-closed: a chargeable card on file (402 `card_required`) and prepaid alone covering the amount (402 `insufficient_prepaid`) \u2014 credits are never consulted, so a GPU cannot draw on a grant. The prepaid gate reads the SAME wallet the debit posts to, so the gate and the charge can never address two wallets. A gate that cannot be READ is 502 and the charge does not happen: unknown is never permission, and a money verdict is never 500-masked.\n\n`amountCents` is whole USD cents and debits EXACTLY, with no rounding \u2014 the ledger holds 18-decimal USD, so the cents asked for are the cents taken.\n\n401 without a validated principal \u2014 a customer charging its OWN wallet, so an absent identity is not signed in, never not authorized.",
|
||||
"description": "Records a gpu-tagged debit against the caller's own org and answers 201 with the transaction id and the prepaid balance left. This is the ONE endpoint on the customer billing surface that moves an org's ledger.\n\n`requestId` IS THE IDEMPOTENCY KEY. Two posts carrying the same one are ONE debit: the ledger recognizes the ref inside the same transaction as the insert, so the replay moves no money and answers the ORIGINAL transaction id — a retry, a proxy replay and a double-clicked launch button all cost one GPU. Send it. OMITTED, the debit takes a fresh ref and is additive, which is the same rule every other write on this ledger states for a missing key: without one there is nothing to recognize a repeat by, and two posts are two charges.\n\nTHE PAYER IS NOT A FIELD. Every billing-subject key in the body — `user`, `userId`, `customerId` — is ignored and the wallet is resolved server-side from the caller's own validated org, so a forged body can never charge another tenant. `amountCents`, `currency`, `requestId`, `notes` and `tag` are the request, and `tag` is FORCED into the gpu bucket so this can never mint a credit-eligible debit.\n\nTwo gates, both fail-closed: a chargeable card on file (402 `card_required`) and prepaid alone covering the amount (402 `insufficient_prepaid`) — credits are never consulted, so a GPU cannot draw on a grant. The prepaid gate reads the SAME wallet the debit posts to, so the gate and the charge can never address two wallets. A gate that cannot be READ is 502 and the charge does not happen: unknown is never permission, and a money verdict is never 500-masked.\n\n`amountCents` is whole USD cents and debits EXACTLY, with no rounding — the ledger holds 18-decimal USD, so the cents asked for are the cents taken.\n\n401 without a validated principal — a customer charging its OWN wallet, so an absent identity is not signed in, never not authorized.",
|
||||
"tags": [
|
||||
"billing"
|
||||
]
|
||||
@@ -43,7 +43,7 @@
|
||||
"get": {
|
||||
"operationId": "get_v1_billing_gpu_eligibility",
|
||||
"summary": "Whether the caller's org may launch a GPU right now, and what is missing",
|
||||
"description": "Answers `eligible` plus the exact `reason` \u2014 `ok`, `card_required` or `insufficient_prepaid` \u2014 with the org's prepaid available, whether a card is on file, and the cents required. It answers 200 in EVERY case: a no is data the launch UI renders as a remedy, never a 402.\n\nEligibility is prepaid REAL money and a chargeable card, both. `creditsRemaining` is reported and is NOT usable \u2014 a GPU debit is gpu-tagged and drawn from the prepaid bucket \u2014 so an org sitting on grant credit with zero prepaid is refused, deliberately.\n\n`amountCents` is the immediate charge and `minPrepaidCents` the 24h floor GPU policy requires; the gate needs prepaid available >= the larger of the two. Both default to 0, so asking with neither answers whether a card exists, not whether a launch is affordable.\n\nThe wallet is pinned server-side to the caller's own org, so this reads exactly the wallet a charge debits \u2014 the gate and the debit can never address two wallets. 401 without a validated principal.",
|
||||
"description": "Answers `eligible` plus the exact `reason` — `ok`, `card_required` or `insufficient_prepaid` — with the org's prepaid available, whether a card is on file, and the cents required. It answers 200 in EVERY case: a no is data the launch UI renders as a remedy, never a 402.\n\nEligibility is prepaid REAL money and a chargeable card, both. `creditsRemaining` is reported and is NOT usable — a GPU debit is gpu-tagged and drawn from the prepaid bucket — so an org sitting on grant credit with zero prepaid is refused, deliberately.\n\n`amountCents` is the immediate charge and `minPrepaidCents` the 24h floor GPU policy requires; the gate needs prepaid available \u003e= the larger of the two. Both default to 0, so asking with neither answers whether a card exists, not whether a launch is affordable.\n\nThe wallet is pinned server-side to the caller's own org, so this reads exactly the wallet a charge debits — the gate and the debit can never address two wallets. 401 without a validated principal.",
|
||||
"tags": [
|
||||
"billing"
|
||||
]
|
||||
@@ -53,7 +53,7 @@
|
||||
"get": {
|
||||
"operationId": "get_v1_billing_usage",
|
||||
"summary": "Every billed call the caller's org made, attributed to a product",
|
||||
"description": "Answers one row per BILLED call against the caller's org \u2014 transaction id, amount, timestamp and the metered unit. This is the raw charged ledger, not a rollup.\n\nEach row is stamped with a canonical `metadata.product` derived from what the meter persisted: `agent` becomes agents, `provisioning` becomes the provisioned kind, a token-metered row becomes inference, anything else keeps its metering surface. The ledger has no product field of its own, so this read is where that dimension is made real \u2014 from the SAME charged rows, never a second meter. A row that already carries its own product WINS, so the derivation stops the day the meter records one.\n\n`product=<id>` filters to one product server-side. `groupBy=product` reduces to `{product,requests,amountCents}` rollups instead of rows.\n\n`amount` is whole USD cents, ROUNDED; `decimal` beside it is the SAME debit exact, as an 18-decimal USD string. Sum `decimal`. A page of sub-cent token calls totals correctly there and totals ZERO in `amount` \u2014 that difference is real money.\n\nScoped to the caller's own org's books, where the org's ledger file IS the tenant boundary; no client-supplied subject is ever forwarded. 401 without a validated principal. The co-resident read returns the 2000 most recent debits, newest first; `start` and `end` narrow the window only on the split-deploy upstream.",
|
||||
"description": "Answers one row per BILLED call against the caller's org — transaction id, amount, timestamp and the metered unit. This is the raw charged ledger, not a rollup.\n\nEach row is stamped with a canonical `metadata.product` derived from what the meter persisted: `agent` becomes agents, `provisioning` becomes the provisioned kind, a token-metered row becomes inference, anything else keeps its metering surface. The ledger has no product field of its own, so this read is where that dimension is made real — from the SAME charged rows, never a second meter. A row that already carries its own product WINS, so the derivation stops the day the meter records one.\n\n`product=\u003cid\u003e` filters to one product server-side. `groupBy=product` reduces to `{product,requests,amountCents}` rollups instead of rows.\n\n`amount` is whole USD cents, ROUNDED; `decimal` beside it is the SAME debit exact, as an 18-decimal USD string. Sum `decimal`. A page of sub-cent token calls totals correctly there and totals ZERO in `amount` — that difference is real money.\n\nScoped to the caller's own org's books, where the org's ledger file IS the tenant boundary; no client-supplied subject is ever forwarded. 401 without a validated principal. The co-resident read returns the 2000 most recent debits, newest first; `start` and `end` narrow the window only on the split-deploy upstream.",
|
||||
"tags": [
|
||||
"billing"
|
||||
]
|
||||
@@ -63,7 +63,7 @@
|
||||
"get": {
|
||||
"operationId": "get_v1_billing_usage_accounts",
|
||||
"summary": "Traffic through the accounts the CALLER linked, per account",
|
||||
"description": "Answers per-account totals for the linked provider accounts the gateway ROUTED this caller's traffic through \u2014 requests, prompt and completion tokens, recorded cost \u2014 plus their honest sum.\n\nThis is the one read in the billing namespace scoped to the PERSON, not the org. Rows are keyed on (validated org, validated user), so a caller sees the accounts THEY linked and never a colleague's, even inside one org \u2014 everything else under /v1/billing is org-wide. Neither key is ever read from the request.\n\nIt is a ROUTING counter, not the money ledger. `costCents` is 0 for an account billed by its own subscription (`billing: \"plan\"`), where the plan pays the provider directly, so these totals do not reconcile against what the org was charged. /v1/billing/usage is the charged ledger.\n\n401 without a validated principal. Where the linked-account plane is not resident the answer is an honest 501 \u2014 never an empty breakdown, which would read as no usage.",
|
||||
"description": "Answers per-account totals for the linked provider accounts the gateway ROUTED this caller's traffic through — requests, prompt and completion tokens, recorded cost — plus their honest sum.\n\nThis is the one read in the billing namespace scoped to the PERSON, not the org. Rows are keyed on (validated org, validated user), so a caller sees the accounts THEY linked and never a colleague's, even inside one org — everything else under /v1/billing is org-wide. Neither key is ever read from the request.\n\nIt is a ROUTING counter, not the money ledger. `costCents` is 0 for an account billed by its own subscription (`billing: \"plan\"`), where the plan pays the provider directly, so these totals do not reconcile against what the org was charged. /v1/billing/usage is the charged ledger.\n\n401 without a validated principal. Where the linked-account plane is not resident the answer is an honest 501 — never an empty breakdown, which would read as no usage.",
|
||||
"tags": [
|
||||
"billing"
|
||||
]
|
||||
@@ -73,7 +73,7 @@
|
||||
"get": {
|
||||
"operationId": "get_v1_finance_balance",
|
||||
"summary": "Spendable prepaid for the caller's org, in the finance shape",
|
||||
"description": "Answers the org's spendable prepaid balance typed for the finance surfaces: `availableCents`, `pendingCents`, `dueCents` and the `asOf` instant it was read.\n\nIt is the SAME wallet read /v1/billing/balance answers \u2014 one function, called by both, so the two surfaces cannot drift into disagreeing about a customer's money. Reshaped, never re-metered. Co-resident the number comes straight out of the org's own double-entry ledger file.\n\n`dueCents` is a structural 0: this is a PREPAID wallet with no open-invoice debt, so nothing is ever owed and a non-zero value here would be an invention. `pendingCents` is 0 on the co-resident ledger, where authorization holds are never posted; only a split-deploy upstream reports holds, and there spendable is the balance NET of them, floored at 0 \u2014 a fully-held wallet reports 0 rather than money the gate would refuse.\n\nCents are ROUNDED from the ledger's exact 18-decimal USD. Scoped to the caller's own org from the validated IAM owner claim; 401 without a validated principal, and a balance that cannot be read is 502 \u2014 never 0, because unknown is not broke.",
|
||||
"description": "Answers the org's spendable prepaid balance typed for the finance surfaces: `availableCents`, `pendingCents`, `dueCents` and the `asOf` instant it was read.\n\nIt is the SAME wallet read /v1/billing/balance answers — one function, called by both, so the two surfaces cannot drift into disagreeing about a customer's money. Reshaped, never re-metered. Co-resident the number comes straight out of the org's own double-entry ledger file.\n\n`dueCents` is a structural 0: this is a PREPAID wallet with no open-invoice debt, so nothing is ever owed and a non-zero value here would be an invention. `pendingCents` is 0 on the co-resident ledger, where authorization holds are never posted; only a split-deploy upstream reports holds, and there spendable is the balance NET of them, floored at 0 — a fully-held wallet reports 0 rather than money the gate would refuse.\n\nCents are ROUNDED from the ledger's exact 18-decimal USD. Scoped to the caller's own org from the validated IAM owner claim; 401 without a validated principal, and a balance that cannot be read is 502 — never 0, because unknown is not broke.",
|
||||
"tags": [
|
||||
"finance"
|
||||
]
|
||||
@@ -83,7 +83,7 @@
|
||||
"get": {
|
||||
"operationId": "get_v1_finance_credits",
|
||||
"summary": "Credit grants and top-ups on the caller's org wallet",
|
||||
"description": "Answers the money PUT IN to the org's wallet \u2014 each staff grant, promo and settled top-up as a positive row with its id, label, cents and grant time.\n\nSpend is not a credit. A posting counts here only when it moved money IN; debits belong to /v1/finance/usage (aggregated) and /v1/finance/ledger (signed). All three project ONE read of the same ledger through ONE vocabulary for what a posting means, so they cannot disagree about a row \u2014 nor silently drop one, which is what an empty credits page against a funded wallet was.\n\n`label` falls back through the posting's notes, then its tags, then a bare Credit \u2014 it is a description, never an identifier. `remainingCents` is OMITTED: the wallet is one running balance, not per-grant buckets, so no grant has a remainder to report and spend cannot be attributed to the credit that funded it.\n\nCents are ROUNDED from the ledger's exact 18-decimal USD. Scoped to the caller's own org; 401 without a validated principal. An org with no grants gets an empty array \u2014 honest, never a fabricated figure.",
|
||||
"description": "Answers the money PUT IN to the org's wallet — each staff grant, promo and settled top-up as a positive row with its id, label, cents and grant time.\n\nSpend is not a credit. A posting counts here only when it moved money IN; debits belong to /v1/finance/usage (aggregated) and /v1/finance/ledger (signed). All three project ONE read of the same ledger through ONE vocabulary for what a posting means, so they cannot disagree about a row — nor silently drop one, which is what an empty credits page against a funded wallet was.\n\n`label` falls back through the posting's notes, then its tags, then a bare Credit — it is a description, never an identifier. `remainingCents` is OMITTED: the wallet is one running balance, not per-grant buckets, so no grant has a remainder to report and spend cannot be attributed to the credit that funded it.\n\nCents are ROUNDED from the ledger's exact 18-decimal USD. Scoped to the caller's own org; 401 without a validated principal. An org with no grants gets an empty array — honest, never a fabricated figure.",
|
||||
"tags": [
|
||||
"finance"
|
||||
]
|
||||
@@ -92,8 +92,8 @@
|
||||
"/v1/finance/invoices": {
|
||||
"get": {
|
||||
"operationId": "get_v1_finance_invoices",
|
||||
"summary": "Issued invoices \u2014 none exist, and that is the honest answer",
|
||||
"description": "Answers an empty typed array, always. The fleet bills a PREPAID wallet \u2014 money in, metered debits out \u2014 and issues no customer invoices, so there is no invoice ledger to project. Nothing here is a fabricated figure and nothing is hidden behind a filter.\n\nThe shape is fixed, so the finance UI renders this lane today and the day an invoice ledger exists it fills with ZERO client change. Spend that actually happened is /v1/finance/usage; money in and out is /v1/finance/ledger; what is left to spend is /v1/finance/balance.\n\nThe gate is real even though the body is empty: 401 without a validated principal. It is the only finance read that touches no store, so it is also the only one that cannot 502.",
|
||||
"summary": "Issued invoices — none exist, and that is the honest answer",
|
||||
"description": "Answers an empty typed array, always. The fleet bills a PREPAID wallet — money in, metered debits out — and issues no customer invoices, so there is no invoice ledger to project. Nothing here is a fabricated figure and nothing is hidden behind a filter.\n\nThe shape is fixed, so the finance UI renders this lane today and the day an invoice ledger exists it fills with ZERO client change. Spend that actually happened is /v1/finance/usage; money in and out is /v1/finance/ledger; what is left to spend is /v1/finance/balance.\n\nThe gate is real even though the body is empty: 401 without a validated principal. It is the only finance read that touches no store, so it is also the only one that cannot 502.",
|
||||
"tags": [
|
||||
"finance"
|
||||
]
|
||||
@@ -103,7 +103,7 @@
|
||||
"get": {
|
||||
"operationId": "get_v1_finance_ledger",
|
||||
"summary": "Money in and out of the caller's org wallet, signed",
|
||||
"description": "Answers the org's own postings inside `range=`, each as a signed entry: a DEPOSIT CREDITS the wallet (positive, account `credits:<org>`) and every other posting DEBITS it (negative, account `usage:<org>`), described by its notes or its tags. The sign is the posting's own meaning, read through ONE vocabulary shared with the ledger that wrote it \u2014 a reader with its own spelling for `deposit` rendered a customer's grant as a charge.\n\nThis is the closest projection of the truth. The org's double-entry postings are the source of record \u2014 balanced, only ever appended, one file per org \u2014 and this lane is that list, widest of the three: /v1/finance/credits is its deposit half and /v1/finance/usage is its withdrawal half rolled up. All three come from ONE read, which is why they cannot contradict each other, and all three answer 501 where no commerce link is configured rather than reporting an empty wallet.\n\n`range` is 24h, 7d, 30d or 90d, defaulting to 30d. A row whose timestamp will not parse is KEPT rather than dropped \u2014 a malformed date must show up in a money list, not vanish from it. `balanceCents` is omitted: these are MOVEMENTS, and the standing balance is /v1/finance/balance.\n\nCents are ROUNDED from the ledger's exact 18-decimal USD. Scoped to the caller's own org, where the org's ledger file is the tenant boundary; 401 without a validated principal.",
|
||||
"description": "Answers the org's own postings inside `range=`, each as a signed entry: a DEPOSIT CREDITS the wallet (positive, account `credits:\u003corg\u003e`) and every other posting DEBITS it (negative, account `usage:\u003corg\u003e`), described by its notes or its tags. The sign is the posting's own meaning, read through ONE vocabulary shared with the ledger that wrote it — a reader with its own spelling for `deposit` rendered a customer's grant as a charge.\n\nThis is the closest projection of the truth. The org's double-entry postings are the source of record — balanced, only ever appended, one file per org — and this lane is that list, widest of the three: /v1/finance/credits is its deposit half and /v1/finance/usage is its withdrawal half rolled up. All three come from ONE read, which is why they cannot contradict each other, and all three answer 501 where no commerce link is configured rather than reporting an empty wallet.\n\n`range` is 24h, 7d, 30d or 90d, defaulting to 30d. A row whose timestamp will not parse is KEPT rather than dropped — a malformed date must show up in a money list, not vanish from it. `balanceCents` is omitted: these are MOVEMENTS, and the standing balance is /v1/finance/balance.\n\nCents are ROUNDED from the ledger's exact 18-decimal USD. Scoped to the caller's own org, where the org's ledger file is the tenant boundary; 401 without a validated principal.",
|
||||
"tags": [
|
||||
"finance"
|
||||
]
|
||||
@@ -113,7 +113,7 @@
|
||||
"get": {
|
||||
"operationId": "get_v1_finance_payment-methods",
|
||||
"summary": "Saved cards for the wallet the caller pays from",
|
||||
"description": "Answers the masked card descriptors for the caller's resolved WALLET \u2014 id, brand, last four, expiry, default flag \u2014 reshaped into the finance contract.\n\nIt re-masks defensively: whatever the upstream sends, at most the trailing four DIGITS survive into `last4`. No card number, no security code and no processor token exists in this shape at all, so an over-returning upstream still cannot leak one through this lane.\n\nRead the sibling difference before trusting a mismatch. This keys the store on the resolved wallet; /v1/billing/methods keys it on the org SLUG, which is also the key a card is SAVED under \u2014 identical for an org paying from its shared pool, different wherever the payer is a person. When the two lists disagree, the billing one is what was saved.\n\n401 without a validated principal. An upstream that answers non-2xx or cannot be reached is 502 \u2014 never an empty list, because no cards and could not ask must not look alike.",
|
||||
"description": "Answers the masked card descriptors for the caller's resolved WALLET — id, brand, last four, expiry, default flag — reshaped into the finance contract.\n\nIt re-masks defensively: whatever the upstream sends, at most the trailing four DIGITS survive into `last4`. No card number, no security code and no processor token exists in this shape at all, so an over-returning upstream still cannot leak one through this lane.\n\nRead the sibling difference before trusting a mismatch. This keys the store on the resolved wallet; /v1/billing/methods keys it on the org SLUG, which is also the key a card is SAVED under — identical for an org paying from its shared pool, different wherever the payer is a person. When the two lists disagree, the billing one is what was saved.\n\n401 without a validated principal. An upstream that answers non-2xx or cannot be reached is 502 — never an empty list, because no cards and could not ask must not look alike.",
|
||||
"tags": [
|
||||
"finance"
|
||||
]
|
||||
@@ -123,7 +123,7 @@
|
||||
"get": {
|
||||
"operationId": "get_v1_finance_usage",
|
||||
"summary": "What the caller's org spent over a window, as a series and by tag",
|
||||
"description": "Answers metered spend inside `range=`: the window total, a time series to plot, and one line per usage TAG. Aggregated from the same charged ledger the balance comes off \u2014 projected, never re-metered.\n\nOnly DEBIT postings count; deposits are credits and are excluded. `range` is 24h, 7d, 30d or 90d, and anything else \u2014 including absent \u2014 is 30d, so a typo silently widens the window to a month rather than failing. Buckets are hourly at 24h and daily otherwise, in UTC; a posting whose timestamp will not parse is dropped rather than mis-bucketed.\n\nLines group by the posting's tag (`Usage` where it carries none) and `units` counts POSTINGS, not tokens. The dimensions here are time and tag. For per-request rows and a per-PRODUCT breakdown, read /v1/billing/usage instead \u2014 the same money, cut a different way.\n\nCents are ROUNDED from the ledger's exact 18-decimal USD, so a window made of sub-cent token calls totals LOW here. Scoped to the caller's own org; 401 without a validated principal.",
|
||||
"description": "Answers metered spend inside `range=`: the window total, a time series to plot, and one line per usage TAG. Aggregated from the same charged ledger the balance comes off — projected, never re-metered.\n\nOnly DEBIT postings count; deposits are credits and are excluded. `range` is 24h, 7d, 30d or 90d, and anything else — including absent — is 30d, so a typo silently widens the window to a month rather than failing. Buckets are hourly at 24h and daily otherwise, in UTC; a posting whose timestamp will not parse is dropped rather than mis-bucketed.\n\nLines group by the posting's tag (`Usage` where it carries none) and `units` counts POSTINGS, not tokens. The dimensions here are time and tag. For per-request rows and a per-PRODUCT breakdown, read /v1/billing/usage instead — the same money, cut a different way.\n\nCents are ROUNDED from the ledger's exact 18-decimal USD, so a window made of sub-cent token calls totals LOW here. Scoped to the caller's own org; 401 without a validated principal.",
|
||||
"tags": [
|
||||
"finance"
|
||||
]
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"paths": {
|
||||
"/v1/captable/classes": {
|
||||
"get": {
|
||||
"operationId": "v1.captable.get_classes",
|
||||
"operationId": "get_v1_captable_classes",
|
||||
"summary": "Returns the caller org's share classes, in creation order.",
|
||||
"description": "Returns the caller org's share classes, in creation order. A\nshare class is what a certificate is issued in, and every class the company\nhas authorized appears. The response is a bare JSON array, not an envelope.",
|
||||
"tags": [
|
||||
@@ -71,7 +71,7 @@
|
||||
},
|
||||
"/v1/captable/company": {
|
||||
"get": {
|
||||
"operationId": "v1.captable.get_company",
|
||||
"operationId": "get_v1_captable_company",
|
||||
"summary": "Returns the caller org's cap-table company record.",
|
||||
"description": "Returns the caller org's cap-table company record. The row is\nseeded when the tenant's store first opens, so it always exists; its name and\nincorporation details are set with PUT /v1/captable/company.",
|
||||
"tags": [
|
||||
@@ -91,7 +91,7 @@
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"operationId": "v1.captable.put_company",
|
||||
"operationId": "put_v1_captable_company",
|
||||
"summary": "Sets the caller org's company name and incorporation details.",
|
||||
"description": "Sets the caller org's company name and incorporation details.\nThe name is required; the three incorporation fields are optional and each is\nstored as empty when omitted, so a call that sends only a name CLEARS them.\nThe company row itself is seeded when the tenant's store first opens, so this\nnever creates one.",
|
||||
"tags": [
|
||||
@@ -123,7 +123,7 @@
|
||||
},
|
||||
"/v1/captable/convertibles": {
|
||||
"get": {
|
||||
"operationId": "v1.captable.get_convertibles",
|
||||
"operationId": "get_v1_captable_convertibles",
|
||||
"summary": "Returns the caller org's convertible notes, newest first.",
|
||||
"description": "Returns the caller org's convertible notes, newest first. A\nnote's principal sits OUTSIDE issued equity until it converts, so it is not\npart of the share counts.",
|
||||
"tags": [
|
||||
@@ -153,7 +153,7 @@
|
||||
},
|
||||
"/v1/captable/convertibles/{id}": {
|
||||
"delete": {
|
||||
"operationId": "v1.captable.delete_convertibles_id",
|
||||
"operationId": "delete_v1_captable_convertibles_id",
|
||||
"summary": "Removes one of the caller org's convertible notes, taking its principal out of the cap table's unconverted-instrument totals.",
|
||||
"description": "Removes one of the caller org's convertible notes, taking its\nprincipal out of the cap table's unconverted-instrument totals. An id this org\ndoes not hold is not found.",
|
||||
"tags": [
|
||||
@@ -186,7 +186,7 @@
|
||||
},
|
||||
"/v1/captable/investments": {
|
||||
"get": {
|
||||
"operationId": "v1.captable.get_investments",
|
||||
"operationId": "get_v1_captable_investments",
|
||||
"summary": "Returns the caller org's investments, newest first.",
|
||||
"description": "Returns the caller org's investments, newest first. It spans\nevery round, so it is the flat ledger of cheques written into the company,\neach naming its investor and the round it went into.",
|
||||
"tags": [
|
||||
@@ -208,7 +208,7 @@
|
||||
},
|
||||
"/v1/captable/options": {
|
||||
"get": {
|
||||
"operationId": "v1.captable.get_options",
|
||||
"operationId": "get_v1_captable_options",
|
||||
"summary": "Returns the caller org's option grants, newest first.",
|
||||
"description": "Returns the caller org's option grants, newest first. Each row is\njoined to its grantee and its equity plan. Grants that are EXERCISED, EXPIRED\nor CANCELLED are listed here but do not dilute the cap table.",
|
||||
"tags": [
|
||||
@@ -238,7 +238,7 @@
|
||||
},
|
||||
"/v1/captable/options/{id}": {
|
||||
"delete": {
|
||||
"operationId": "v1.captable.delete_options_id",
|
||||
"operationId": "delete_v1_captable_options_id",
|
||||
"summary": "Removes one of the caller org's option grants, taking its shares out of the cap table's granted-options and fully-diluted counts.",
|
||||
"description": "Removes one of the caller org's option grants, taking its shares\nout of the cap table's granted-options and fully-diluted counts. An id this org\ndoes not hold is not found.",
|
||||
"tags": [
|
||||
@@ -271,7 +271,7 @@
|
||||
},
|
||||
"/v1/captable/plans": {
|
||||
"get": {
|
||||
"operationId": "v1.captable.get_plans",
|
||||
"operationId": "get_v1_captable_plans",
|
||||
"summary": "Returns the caller org's equity plans, newest first.",
|
||||
"description": "Returns the caller org's equity plans, newest first. An equity\nplan is an option pool: a reserve of shares, drawn from one share class, that\noption grants are written against.",
|
||||
"tags": [
|
||||
@@ -301,7 +301,7 @@
|
||||
},
|
||||
"/v1/captable/rounds": {
|
||||
"get": {
|
||||
"operationId": "v1.captable.get_rounds",
|
||||
"operationId": "get_v1_captable_rounds",
|
||||
"summary": "Returns the caller org's fundraising rounds, newest first.",
|
||||
"description": "Returns the caller org's fundraising rounds, newest first. A round\ngroups a fundraising event; a PRICED round also carries the share class and\nprice per share it issues at.",
|
||||
"tags": [
|
||||
@@ -331,7 +331,7 @@
|
||||
},
|
||||
"/v1/captable/rounds/{id}": {
|
||||
"get": {
|
||||
"operationId": "v1.captable.get_rounds_id",
|
||||
"operationId": "get_v1_captable_rounds_id",
|
||||
"summary": "Returns one of the caller org's fundraising rounds together with every investment written into it, oldest first.",
|
||||
"description": "Returns one of the caller org's fundraising rounds together with every\ninvestment written into it, oldest first. A round id that does not exist in the\ncaller's org is not found — including one that exists in another tenant, since\nthe org comes from the caller's principal and is part of the lookup.",
|
||||
"tags": [
|
||||
@@ -364,7 +364,7 @@
|
||||
},
|
||||
"/v1/captable/rounds/{id}/close": {
|
||||
"post": {
|
||||
"operationId": "v1.captable.post_rounds_id_close",
|
||||
"operationId": "post_v1_captable_rounds_id_close",
|
||||
"summary": "Closes one of the caller org's fundraising rounds, recording the close date and moving its status to CLOSED.",
|
||||
"description": "Closes one of the caller org's fundraising rounds, recording the\nclose date and moving its status to CLOSED. Only an OPEN round can be closed:\na round that is already closed — like an id this org does not hold — is not\nfound. Closing a round does not change what was invested in it.",
|
||||
"tags": [
|
||||
@@ -427,7 +427,7 @@
|
||||
},
|
||||
"/v1/captable/safes": {
|
||||
"get": {
|
||||
"operationId": "v1.captable.get_safes",
|
||||
"operationId": "get_v1_captable_safes",
|
||||
"summary": "Returns the caller org's SAFEs, newest first.",
|
||||
"description": "Returns the caller org's SAFEs, newest first. A SAFE is a simple\nagreement for future equity: its capital sits OUTSIDE issued equity until it\nconverts, so it is not part of the share counts.",
|
||||
"tags": [
|
||||
@@ -457,7 +457,7 @@
|
||||
},
|
||||
"/v1/captable/safes/{id}": {
|
||||
"delete": {
|
||||
"operationId": "v1.captable.delete_safes_id",
|
||||
"operationId": "delete_v1_captable_safes_id",
|
||||
"summary": "Removes one of the caller org's SAFEs, taking its capital out of the cap table's unconverted-instrument totals.",
|
||||
"description": "Removes one of the caller org's SAFEs, taking its capital out of the\ncap table's unconverted-instrument totals. An id this org does not hold is not\nfound.",
|
||||
"tags": [
|
||||
@@ -490,7 +490,7 @@
|
||||
},
|
||||
"/v1/captable/shares": {
|
||||
"get": {
|
||||
"operationId": "v1.captable.get_shares",
|
||||
"operationId": "get_v1_captable_shares",
|
||||
"summary": "Returns the caller org's share certificates, newest first.",
|
||||
"description": "Returns the caller org's share certificates, newest first. Each row\nis joined to its holder and its share class, so a certificate names who holds\nit and what class it is in without a second call.",
|
||||
"tags": [
|
||||
@@ -530,7 +530,7 @@
|
||||
},
|
||||
"/v1/captable/shares/{id}": {
|
||||
"delete": {
|
||||
"operationId": "v1.captable.delete_shares_id",
|
||||
"operationId": "delete_v1_captable_shares_id",
|
||||
"summary": "Removes one of the caller org's share certificates, taking its shares out of the cap table's outstanding and fully-diluted counts.",
|
||||
"description": "Removes one of the caller org's share certificates, taking its\nshares out of the cap table's outstanding and fully-diluted counts. An id this\norg does not hold is not found.",
|
||||
"tags": [
|
||||
@@ -563,7 +563,7 @@
|
||||
},
|
||||
"/v1/captable/stakeholders": {
|
||||
"get": {
|
||||
"operationId": "v1.captable.get_stakeholders",
|
||||
"operationId": "get_v1_captable_stakeholders",
|
||||
"summary": "Returns the caller org's stakeholders, newest first.",
|
||||
"description": "Returns the caller org's stakeholders, newest first. The\nresponse is a bare JSON array, not an envelope. Each row carries the holder's\ncontact and address fields alongside the company's name.",
|
||||
"tags": [
|
||||
@@ -596,7 +596,7 @@
|
||||
},
|
||||
"/v1/captable/stakeholders/{id}": {
|
||||
"delete": {
|
||||
"operationId": "v1.captable.delete_stakeholders_id",
|
||||
"operationId": "delete_v1_captable_stakeholders_id",
|
||||
"summary": "Removes one of the caller org's stakeholders.",
|
||||
"description": "Removes one of the caller org's stakeholders. It REFUSES to\norphan issued equity: a holder that still holds share certificates or option\ngrants cannot be deleted, and answers 400 saying so — release or transfer the\nholdings first. An id this org does not hold is not found.",
|
||||
"tags": [
|
||||
@@ -627,7 +627,7 @@
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "v1.captable.patch_stakeholders_id",
|
||||
"operationId": "patch_v1_captable_stakeholders_id",
|
||||
"summary": "Changes one of the caller org's stakeholders.",
|
||||
"description": "Changes one of the caller org's stakeholders. It is a\nPARTIAL update: only the fields the request names are written, and a field\nsent as null clears that column. A request that names no updatable field is\nrefused, and an id this org does not hold is not found.\n\nThe values are stored as sent. Unlike adding a stakeholder, this route does\nnot check the email's shape or the type and relationship vocabularies, so it\ncan record a value that adding one would have rejected.",
|
||||
"tags": [
|
||||
@@ -670,7 +670,7 @@
|
||||
},
|
||||
"/v1/captable/summary": {
|
||||
"get": {
|
||||
"operationId": "v1.captable.get_summary",
|
||||
"operationId": "get_v1_captable_summary",
|
||||
"summary": "Computes the caller org's cap table.",
|
||||
"description": "Computes the caller org's cap table. It answers who owns what on a\nfully-diluted basis: outstanding shares, granted options, per-stakeholder\nownership percentages, each share class's authorized versus issued position,\nand the capital sitting on SAFEs and convertible notes that have not yet\nconverted. Only non-terminal option grants dilute — EXERCISED, EXPIRED and\nCANCELLED grants are excluded, so equity issued through an exercised option is\nnever counted twice.",
|
||||
"tags": [
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"paths": {
|
||||
"/v1/channels": {
|
||||
"get": {
|
||||
"operationId": "get_v1_channels",
|
||||
"operationId": "v1.get_channels",
|
||||
"summary": "Returns every chat transport channels can talk to — Discord, Slack, Teams and Telegram — with the caller org's own facts on each: whether it is connected and to which account, what the transport supports, the org's DM and group access policies, and how many pairing requests are pending approval.",
|
||||
"description": "Returns every chat transport channels can talk to — Discord, Slack, Teams\nand Telegram — with the caller org's own facts on each: whether it is\nconnected and to which account, what the transport supports, the org's DM and\ngroup access policies, and how many pairing requests are pending approval. The\norder is fixed, so a console can render the same rows every time. A policy that\ncannot be read leaves that channel's policy fields empty rather than failing\nthe whole listing.",
|
||||
"tags": [
|
||||
@@ -40,7 +40,7 @@
|
||||
},
|
||||
"/v1/channels/allowlist": {
|
||||
"get": {
|
||||
"operationId": "get_v1_channels_allowlist",
|
||||
"operationId": "v1.channels.get_allowlist",
|
||||
"summary": "Returns the caller org's access policy for one channel: whether DMs are pairing-gated, allowlisted or open, whether group rooms are open, allowlisted or disabled, the config-managed DM and group allow entries, the senders approved through PAIRING (read-only here), and the org's named access groups.",
|
||||
"description": "Returns the caller org's access policy for one channel: whether\nDMs are pairing-gated, allowlisted or open, whether group rooms are open,\nallowlisted or disabled, the config-managed DM and group allow entries, the\nsenders approved through PAIRING (read-only here), and the org's named access\ngroups. An unknown channel is a 404.",
|
||||
"tags": [
|
||||
@@ -72,7 +72,7 @@
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"operationId": "put_v1_channels_allowlist",
|
||||
"operationId": "v1.channels.put_allowlist",
|
||||
"summary": "Edits the caller org's access policy for one channel and answers the policy as GET would, so both verbs return ONE shape.",
|
||||
"description": "Edits the caller org's access policy for one channel and answers\nthe policy as GET would, so both verbs return ONE shape. It requires ORG ADMIN.\nEvery field but `channel` is optional and applied only when provided: an empty\npolicy string leaves that policy alone, an absent or null list leaves that list\nalone, and an EMPTY list clears it. It writes only CONFIG-sourced allow entries\n— senders approved through pairing belong to the approval flow, so a policy\nedit can never revoke one. An unknown channel is a 404.",
|
||||
"tags": [
|
||||
@@ -111,7 +111,7 @@
|
||||
},
|
||||
"/v1/channels/inbox": {
|
||||
"get": {
|
||||
"operationId": "get_v1_channels_inbox",
|
||||
"operationId": "v1.channels.get_inbox",
|
||||
"summary": "Returns the messages people have sent to the caller org's connected chat bots, oldest first, in the portable envelope shape every transport normalises into.",
|
||||
"description": "Returns the messages people have sent to the caller org's connected chat\nbots, oldest first, in the portable envelope shape every transport normalises\ninto. It is a CURSOR feed, not a search: pass the returned cursor back as\n`since` to get only what has arrived since. Only this org's messages are\nstored under this org, so the feed can never carry another tenant's chat.",
|
||||
"tags": [
|
||||
@@ -155,7 +155,7 @@
|
||||
},
|
||||
"/v1/channels/pairing": {
|
||||
"get": {
|
||||
"operationId": "get_v1_channels_pairing",
|
||||
"operationId": "v1.channels.get_pairing",
|
||||
"summary": "Returns the pairing requests waiting for the caller org to approve — one per person who messaged a connected bot on a channel whose DM policy is \"pairing\" and who is not allowed yet.",
|
||||
"description": "Returns the pairing requests waiting for the caller org to approve\n— one per person who messaged a connected bot on a channel whose DM policy is\n\"pairing\" and who is not allowed yet. Each row carries the CODE an org admin\npasses to POST /v1/channels/pairing/approve. Expired requests are not\nreturned. Codes are capability strings: they are shown here, and never logged.",
|
||||
"tags": [
|
||||
@@ -177,7 +177,7 @@
|
||||
},
|
||||
"/v1/channels/pairing/approve": {
|
||||
"post": {
|
||||
"operationId": "post_v1_channels_pairing_approve",
|
||||
"operationId": "v1.channels.post_pairing_approve",
|
||||
"summary": "Turns one pending pairing code into a standing allow entry, so that person can DM the org's bot on that channel from now on.",
|
||||
"description": "Turns one pending pairing code into a standing allow entry, so\nthat person can DM the org's bot on that channel from now on. It requires ORG\nADMIN, not merely membership. The first approval an org makes on a channel also\nbootstraps that sender as the channel's owner, which the answer reports. An\nunknown or expired code is a 404, and a code always belongs to exactly one\norg, so it can never approve someone into another tenant.",
|
||||
"tags": [
|
||||
|
||||
+1891
-46
File diff suppressed because it is too large
Load Diff
+78
-78
@@ -91,7 +91,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Application"
|
||||
"$ref": "#/components/schemas/ProgramApplication"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -138,7 +138,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Application"
|
||||
"$ref": "#/components/schemas/ProgramApplication"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -712,81 +712,6 @@
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Application": {
|
||||
"properties": {
|
||||
"company": {
|
||||
"description": "Company is the applicant's company name.",
|
||||
"type": "string"
|
||||
},
|
||||
"companyId": {
|
||||
"description": "CompanyID is the CRM Company minted for this lead at intake, so the startup\nalso appears in the org's standard CRM tabs. Empty when that best-effort\nprojection did not run.",
|
||||
"type": "string"
|
||||
},
|
||||
"contactId": {
|
||||
"description": "ContactID is the CRM Contact minted for this lead at intake. Empty when that\nbest-effort projection did not run.",
|
||||
"type": "string"
|
||||
},
|
||||
"contactName": {
|
||||
"description": "ContactName is the person who applied.",
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"description": "CreatedAt is the unix second the application arrived. Server-owned.",
|
||||
"type": "integer"
|
||||
},
|
||||
"email": {
|
||||
"description": "Email is the applicant's email — half of the (email, company) key a\nresubmission refreshes instead of duplicating.",
|
||||
"type": "string"
|
||||
},
|
||||
"events": {
|
||||
"description": "Events is the append-only stage-transition log, oldest first.",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/StageEvent"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"id": {
|
||||
"description": "ID is the server-minted application id (\"appl_\" + 128 random bits).",
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
},
|
||||
"description": "Metadata is the FULL submitted form, every field, including the arrays the\npromoted columns above do not carry (tier1Investors, useCases) and the\ndeterministic tier1Matched list.",
|
||||
"type": "object"
|
||||
},
|
||||
"reason": {
|
||||
"description": "Reason is why the application was rejected, required to reject. Empty\notherwise.",
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"description": "Role is the applicant's role at their company.",
|
||||
"type": "string"
|
||||
},
|
||||
"screen": {
|
||||
"$ref": "#/components/schemas/ScreenResult",
|
||||
"description": "Screen is the AI screen. It runs after intake, so a freshly created\napplication carries a \"pending\" screen."
|
||||
},
|
||||
"stage": {
|
||||
"description": "Stage is the pipeline stage: applied, screened, qualified, credits-offered,\nonboarded or rejected. Server-owned — it starts at \"applied\" and moves only\nthrough the transition machine.",
|
||||
"type": "string"
|
||||
},
|
||||
"tier1": {
|
||||
"description": "Tier1 is whether the applicant is tier-1 backed, derived deterministically\nat intake from the submitted fund list — independent of the AI screen.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"updatedAt": {
|
||||
"description": "UpdatedAt is the unix second of the last write. Server-owned.",
|
||||
"type": "integer"
|
||||
},
|
||||
"website": {
|
||||
"description": "Website is the applicant's website as submitted.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"Company": {
|
||||
"properties": {
|
||||
"arr": {
|
||||
@@ -942,6 +867,81 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ProgramApplication": {
|
||||
"properties": {
|
||||
"company": {
|
||||
"description": "Company is the applicant's company name.",
|
||||
"type": "string"
|
||||
},
|
||||
"companyId": {
|
||||
"description": "CompanyID is the CRM Company minted for this lead at intake, so the startup\nalso appears in the org's standard CRM tabs. Empty when that best-effort\nprojection did not run.",
|
||||
"type": "string"
|
||||
},
|
||||
"contactId": {
|
||||
"description": "ContactID is the CRM Contact minted for this lead at intake. Empty when that\nbest-effort projection did not run.",
|
||||
"type": "string"
|
||||
},
|
||||
"contactName": {
|
||||
"description": "ContactName is the person who applied.",
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"description": "CreatedAt is the unix second the application arrived. Server-owned.",
|
||||
"type": "integer"
|
||||
},
|
||||
"email": {
|
||||
"description": "Email is the applicant's email — half of the (email, company) key a\nresubmission refreshes instead of duplicating.",
|
||||
"type": "string"
|
||||
},
|
||||
"events": {
|
||||
"description": "Events is the append-only stage-transition log, oldest first.",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/StageEvent"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"id": {
|
||||
"description": "ID is the server-minted application id (\"appl_\" + 128 random bits).",
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
},
|
||||
"description": "Metadata is the FULL submitted form, every field, including the arrays the\npromoted columns above do not carry (tier1Investors, useCases) and the\ndeterministic tier1Matched list.",
|
||||
"type": "object"
|
||||
},
|
||||
"reason": {
|
||||
"description": "Reason is why the application was rejected, required to reject. Empty\notherwise.",
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"description": "Role is the applicant's role at their company.",
|
||||
"type": "string"
|
||||
},
|
||||
"screen": {
|
||||
"$ref": "#/components/schemas/ScreenResult",
|
||||
"description": "Screen is the AI screen. It runs after intake, so a freshly created\napplication carries a \"pending\" screen."
|
||||
},
|
||||
"stage": {
|
||||
"description": "Stage is the pipeline stage: applied, screened, qualified, credits-offered,\nonboarded or rejected. Server-owned — it starts at \"applied\" and moves only\nthrough the transition machine.",
|
||||
"type": "string"
|
||||
},
|
||||
"tier1": {
|
||||
"description": "Tier1 is whether the applicant is tier-1 backed, derived deterministically\nat intake from the submitted fund list — independent of the AI screen.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"updatedAt": {
|
||||
"description": "UpdatedAt is the unix second of the last write. Server-owned.",
|
||||
"type": "integer"
|
||||
},
|
||||
"website": {
|
||||
"description": "Website is the applicant's website as submitted.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ScreenResult": {
|
||||
"properties": {
|
||||
"draftReply": {
|
||||
@@ -1013,7 +1013,7 @@
|
||||
"data": {
|
||||
"description": "Data is the page of applications, newest first.",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Application"
|
||||
"$ref": "#/components/schemas/ProgramApplication"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"paths": {
|
||||
"/v1/dataroom/analytics/dataroom/{dataroomId}": {
|
||||
"get": {
|
||||
"operationId": "v1.dataroom.get_analytics_dataroom_dataroomId",
|
||||
"operationId": "get_v1_dataroom_analytics_dataroom_dataroomId",
|
||||
"summary": "Rolls up every share link pointing at one data room: session and page-view totals for the room, plus the per-page breakdown for each link beneath it.",
|
||||
"description": "Rolls up every share link pointing at one data room:\nsession and page-view totals for the room, plus the per-page breakdown for each\nlink beneath it.\n\nA room id outside the caller's own tenant store is not found. Only links that\nNAME the room are counted — a link created over a single document contributes\nnothing here, even when that document also sits in the room.",
|
||||
"tags": [
|
||||
@@ -51,7 +51,7 @@
|
||||
},
|
||||
"/v1/dataroom/analytics/link/{linkId}": {
|
||||
"get": {
|
||||
"operationId": "v1.dataroom.get_analytics_link_linkId",
|
||||
"operationId": "get_v1_dataroom_analytics_link_linkId",
|
||||
"summary": "Reports how one share link was actually read: total viewing sessions, total page views, and per page the view count, the summed dwell measure and its average.",
|
||||
"description": "Reports how one share link was actually read: total viewing\nsessions, total page views, and per page the view count, the summed dwell\nmeasure and its average.\n\nThe link is resolved in the caller's OWN tenant store, so another org's link id\nis not found — knowing a link id is enough to OPEN the room it shares, and\nnever enough to read who has been reading it.",
|
||||
"tags": [
|
||||
@@ -84,7 +84,7 @@
|
||||
},
|
||||
"/v1/dataroom/datarooms": {
|
||||
"get": {
|
||||
"operationId": "v1.dataroom.get_datarooms",
|
||||
"operationId": "get_v1_dataroom_datarooms",
|
||||
"summary": "Returns every data room in the caller org's own store, newest first, with its short public id, name, description and timestamps.",
|
||||
"description": "Returns every data room in the caller org's own store, newest\nfirst, with its short public id, name, description and timestamps.\n\nDocuments are not included — a room's contents come from reading the single\nroom.",
|
||||
"tags": [
|
||||
@@ -104,7 +104,7 @@
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"operationId": "v1.dataroom.post_datarooms",
|
||||
"operationId": "post_v1_dataroom_datarooms",
|
||||
"summary": "Opens a new data room for the caller org and answers with it, including the short public id it is addressed by.",
|
||||
"description": "Opens a new data room for the caller org and answers with it,\nincluding the short public id it is addressed by.\n\n`name` is required; without it the call is refused and the tenant store is\nuntouched, because a dispatch answering 4xx rolls its transaction back. A new\nroom holds no documents and is reachable by NOBODY until a share link is\ncreated over it — opening a room and granting access are two separate acts, so\na room cannot leak by existing.",
|
||||
"tags": [
|
||||
@@ -136,7 +136,7 @@
|
||||
},
|
||||
"/v1/dataroom/datarooms/{id}": {
|
||||
"get": {
|
||||
"operationId": "v1.dataroom.get_datarooms_id",
|
||||
"operationId": "get_v1_dataroom_datarooms_id",
|
||||
"summary": "Reads one of the caller org's data rooms together with every document in it, each carrying its membership id and order index.",
|
||||
"description": "Reads one of the caller org's data rooms together with every\ndocument in it, each carrying its membership id and order index.\n\nThe documents are sorted by that index with unordered ones last and creation\ntime breaking ties — the SAME order a link's visitor sees, so this is what the\nroom looks like from the outside. A room id outside the caller's own tenant\nstore is not found.",
|
||||
"tags": [
|
||||
@@ -169,7 +169,7 @@
|
||||
},
|
||||
"/v1/dataroom/datarooms/{id}/documents": {
|
||||
"post": {
|
||||
"operationId": "v1.dataroom.post_datarooms_id_documents",
|
||||
"operationId": "post_v1_dataroom_datarooms_id_documents",
|
||||
"summary": "Puts an already-uploaded document into one of the caller org's data rooms and answers with the new membership id.",
|
||||
"description": "Puts an already-uploaded document into one of the caller\norg's data rooms and answers with the new membership id.\n\nIt ATTACHES, it never uploads: the bytes must already be stored, so the usual\norder is upload the document, then add it to the room. Both the room and the\ndocument must exist in the caller's own store — either missing is not found —\nand a document already in the room is refused as a conflict rather than\nduplicated.",
|
||||
"tags": [
|
||||
@@ -212,7 +212,7 @@
|
||||
},
|
||||
"/v1/dataroom/documents": {
|
||||
"get": {
|
||||
"operationId": "v1.dataroom.get_documents",
|
||||
"operationId": "get_v1_dataroom_documents",
|
||||
"summary": "Returns every document in the caller org's own store, newest first — name, opaque storage key, content type, page count, size and timestamps.",
|
||||
"description": "Returns every document in the caller org's own store, newest\nfirst — name, opaque storage key, content type, page count, size and\ntimestamps.\n\nTenant isolation is the per-org store itself: there is one SQLite file per org\nand the org is never a parameter, so no input the caller controls can address\nanother tenant's documents. Metadata only — the bytes come from the file route.",
|
||||
"tags": [
|
||||
@@ -242,7 +242,7 @@
|
||||
},
|
||||
"/v1/dataroom/documents/{id}": {
|
||||
"get": {
|
||||
"operationId": "v1.dataroom.get_documents_id",
|
||||
"operationId": "get_v1_dataroom_documents_id",
|
||||
"summary": "Reads one of the caller org's documents — its name, opaque storage key, content type, page count, size and timestamps.",
|
||||
"description": "Reads one of the caller org's documents — its name, opaque storage\nkey, content type, page count, size and timestamps.\n\nThe lookup runs in the caller's own tenant store, so an id belonging to another\norg is not found exactly like one that never existed. Metadata only: the bytes\nare a separate read.",
|
||||
"tags": [
|
||||
@@ -305,7 +305,7 @@
|
||||
},
|
||||
"/v1/dataroom/links": {
|
||||
"get": {
|
||||
"operationId": "v1.dataroom.get_links",
|
||||
"operationId": "get_v1_dataroom_links",
|
||||
"summary": "Returns every live share link in the caller org's own store, newest first, with the controls a visitor will meet: whether an address is required, whether a password is set, the allow and deny lists, whether download is permitted, and when the link expires.",
|
||||
"description": "Returns every live share link in the caller org's own store,\nnewest first, with the controls a visitor will meet: whether an address is\nrequired, whether a password is set, the allow and deny lists, whether download\nis permitted, and when the link expires.\n\nArchived links are omitted entirely. A link reports only THAT a password is\nset — the stored form is a bcrypt hash and no route returns it.",
|
||||
"tags": [
|
||||
@@ -325,7 +325,7 @@
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"operationId": "v1.dataroom.post_links",
|
||||
"operationId": "post_v1_dataroom_links",
|
||||
"summary": "Grants access: it mints a public share link over one data room (`dataroomId`) or one document (`documentId`) — one of the two is required — and answers with the link, whose `id` is the token a visitor opens it with.",
|
||||
"description": "Grants access: it mints a public share link over one data\nroom (`dataroomId`) or one document (`documentId`) — one of the two is\nrequired — and answers with the link, whose `id` is the token a visitor opens\nit with.\n\nThis is how a party is let in. The controls are declared HERE and enforced on\nthe viewer surface: `password` is hashed with bcrypt before storage and is\nnever readable back, `emailProtected` (on by default) makes a visitor state an\naddress, `allowList`/`denyList` narrow which addresses pass, `allowDownload`\n(off by default) governs downloads, and `expiresAt` closes the link. The target\nroom or document must exist in the caller's own store or it is not found.\n\nCreating a link also writes dataroom's ONE cross-tenant row: the link id to\nowning org mapping an anonymous visitor is routed through. That write is part\nof the operation — if it fails the call is 500 — so a link that no visitor\ncould open is never handed back as usable.\n\nThe address a visitor later states is recorded UNVERIFIED, so a link gated only\nby email is openable by anyone the link reaches. Use a password for a link that\nmust not travel.",
|
||||
"tags": [
|
||||
|
||||
@@ -314,7 +314,7 @@
|
||||
"user": "u_alice"
|
||||
},
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Role"
|
||||
"$ref": "#/components/schemas/RoleAssignment"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -325,7 +325,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Role"
|
||||
"$ref": "#/components/schemas/RoleAssignment"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -848,12 +848,14 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"Role": {
|
||||
"RoleAssignment": {
|
||||
"properties": {
|
||||
"role": {
|
||||
"description": "Role is the granted role's name.",
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"description": "User is the member the role is granted to.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
@@ -903,7 +905,7 @@
|
||||
"data": {
|
||||
"description": "Data is every (user, role) assignment in the caller's org.",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Role"
|
||||
"$ref": "#/components/schemas/RoleAssignment"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
|
||||
+345
-345
File diff suppressed because it is too large
Load Diff
@@ -868,6 +868,16 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v1/integrations/slack/install": {
|
||||
"get": {
|
||||
"operationId": "get_v1_integrations_slack_install",
|
||||
"summary": "Install the Hanzo app into a Slack workspace",
|
||||
"description": "The address behind Slack's \"Add to Slack\" and Marketplace Install buttons. It answers a 302 to Slack's own consent screen and does nothing else — it is a redirector by design.\n\nIt exists because Slack refuses a slack.com URL in that field and requires one of ours that redirects there, which makes the field an ATTRIBUTION hook: routing the click through our own address is what lets an install be counted, and always answering the redirect is what keeps the counter from becoming a detour that never reaches consent. The destination is the same consent URL every time, built from the same scopes the console's Connect button asks for, so a workspace is asked to grant one thing however the install began.\n\nIt is PUBLIC and carries no principal, because whoever clicks Install in Slack's directory has no Hanzo session yet. It binds no org either, and that is deliberate rather than missing: the org is resolved at the shared provider callback, from the signed state a console connect minted or from the workspace's existing connection. Minting an org for an anonymous click is the one thing that would break tenant isolation, so an install begun here finishes under exactly the rules every other install obeys.",
|
||||
"tags": [
|
||||
"integrations"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v1/integrations/slack/link": {
|
||||
"get": {
|
||||
"operationId": "get_v1_integrations_slack_link",
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
"u_1",
|
||||
"u_2"
|
||||
],
|
||||
"source": "event.event",
|
||||
"source": "event.fact",
|
||||
"unmatched": 497
|
||||
},
|
||||
"schema": {
|
||||
|
||||
@@ -12810,6 +12810,59 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/o11y/traces": {
|
||||
"get": {
|
||||
"operationId": "get_v1_o11y_traces",
|
||||
"summary": "Lists the caller org's recent traces — one row per trace with its span count and wall-clock duration, most recently active first.",
|
||||
"description": "Lists the caller org's recent traces — one row per trace with\nits span count and wall-clock duration, most recently active first. This is\nthe trace SEARCH: it is where a trace id comes from, and the spans behind any\nrow are then read from GET /v1/o11y/traces/{traceId}. Every row belongs to the\ncaller's own org — the tenant is the validated principal, never an input, and\nthere is no administrator widening, because a trace list is a tenant's records\nrather than a rollup over them. An unreachable telemetry store answers 503\nrather than an empty page, because \"no traces\" and \"cannot see the traces\" are\ndifferent facts and only one of them is about the caller's system.",
|
||||
"tags": [
|
||||
"o11y"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "range",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Range is the window in seconds, counted back from now over each trace's\nlast activity. Default 3600, capped at 604800 (7d).",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"example": 3600
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Limit is how many traces to return. Default 50, capped at 500.",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
},
|
||||
"example": 50
|
||||
},
|
||||
{
|
||||
"name": "minDurationMs",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "MinDurationMs keeps only traces that lasted at least this many\nmilliseconds. Zero or absent keeps every trace in the window.",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/o11y.tracesOut"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "ok"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/o11y/traces/fields": {
|
||||
"get": {
|
||||
"operationId": "GetTraceFields",
|
||||
@@ -31381,6 +31434,55 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"o11y.traceRow": {
|
||||
"properties": {
|
||||
"durationMs": {
|
||||
"description": "DurationMs is End minus Start in milliseconds: the trace's wall clock,\nnot the sum of its spans, which double-counts everything concurrent.",
|
||||
"type": "number"
|
||||
},
|
||||
"end": {
|
||||
"description": "End is the latest span end, RFC3339 with nanoseconds, in UTC.",
|
||||
"type": "string"
|
||||
},
|
||||
"numSpans": {
|
||||
"description": "NumSpans is how many spans the trace carries.",
|
||||
"type": "integer"
|
||||
},
|
||||
"start": {
|
||||
"description": "Start is the earliest span start, RFC3339 with nanoseconds, in UTC.",
|
||||
"type": "string"
|
||||
},
|
||||
"traceId": {
|
||||
"description": "TraceID is the trace's id — the {traceId} of the detail read.",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"o11y.tracesOut": {
|
||||
"properties": {
|
||||
"count": {
|
||||
"description": "Count is how many traces this page carries.",
|
||||
"type": "integer"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Limit is the page cap actually applied, after clamping.",
|
||||
"type": "integer"
|
||||
},
|
||||
"sinceSec": {
|
||||
"description": "SinceSec is the window actually read, in seconds, after clamping.",
|
||||
"type": "integer"
|
||||
},
|
||||
"traces": {
|
||||
"description": "Traces are the caller org's traces, most recently active first.",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/o11y.traceRow"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"o11y.updateItemIn": {
|
||||
"properties": {
|
||||
"assignee": {
|
||||
|
||||
@@ -308,7 +308,7 @@
|
||||
"post": {
|
||||
"operationId": "post_v1_platform_sites_slug_domains",
|
||||
"summary": "Attaches one or more CUSTOM public hostnames to this org's site.",
|
||||
"description": "Attaches one or more CUSTOM public hostnames to this org's site.\n\nBinding a host you do not own would let you shadow it at the edge, so which\noutcome you get depends on whether ownership is already established: a caller\nvouches (a SuperAdmin, or an ADMIN of a platform-operator org — which manages\ncustomer DNS, so its admin's bind IS the vouch) and binds VERIFIED immediately;\nevery other caller, INCLUDING a plain member of an operator org, has the host\nCLAIMED as pending and gets the DNS challenge back in `bound[].records`. A\npending claim HOLDS the name so nobody else can take it, but it does not route\nuntil POST .../domains/{host}/verify proves control.\n\nA hostname we operate is refused to a non-vouched caller (those are assigned\nby the platform, never claimed), a host another site already holds is a 409,\nand a name the platform holds is a 400 for EVERY caller — a vouch skips the\nownership proof, never the host table's own invariant. Claims and binds are\nidempotent for the same\n(org, slug), and re-claiming returns the SAME token rather than invalidating a\nrecord the customer has already published. The edge cache-tag is flushed\nafterwards so a newly-verified host serves the current build immediately.\n\nScope: a validated principal is required (403 without one) and the site is\nresolved within that principal's org, so another tenant's slug is a 404.",
|
||||
"description": "Attaches one or more CUSTOM public hostnames to this org's site.\n\nBinding a host you do not own would let you shadow it at the edge, so which\noutcome you get depends on whether ownership is already established: a SuperAdmin\nvouches (the operator manages the customer's DNS, so its bind IS the proof) and\nbinds VERIFIED immediately; every other caller, INCLUDING an admin of the\ndeployment's own brand org, has the host CLAIMED as pending and gets the DNS\nchallenge back in `bound[].records`. A pending claim HOLDS the name so nobody\nelse can take it, but it does not route until POST .../domains/{host}/verify\nproves control.\n\nA hostname we operate is refused to a non-vouched caller (those are assigned\nby the platform, never claimed), a host another site already holds is a 409,\nand a name the platform holds is a 400 for EVERY caller — a vouch skips the\nownership proof, never the host table's own invariant. Claims and binds are\nidempotent for the same\n(org, slug), and re-claiming returns the SAME token rather than invalidating a\nrecord the customer has already published. The edge cache-tag is flushed\nafterwards so a newly-verified host serves the current build immediately.\n\nScope: a validated principal is required (403 without one) and the site is\nresolved within that principal's org, so another tenant's slug is a 404.",
|
||||
"tags": [
|
||||
"platform"
|
||||
],
|
||||
@@ -989,7 +989,7 @@
|
||||
"post": {
|
||||
"operationId": "post_v1_projects_slug_domains",
|
||||
"summary": "Attaches one or more CUSTOM public hostnames to this org's site.",
|
||||
"description": "Attaches one or more CUSTOM public hostnames to this org's site.\n\nBinding a host you do not own would let you shadow it at the edge, so which\noutcome you get depends on whether ownership is already established: a caller\nvouches (a SuperAdmin, or an ADMIN of a platform-operator org — which manages\ncustomer DNS, so its admin's bind IS the vouch) and binds VERIFIED immediately;\nevery other caller, INCLUDING a plain member of an operator org, has the host\nCLAIMED as pending and gets the DNS challenge back in `bound[].records`. A\npending claim HOLDS the name so nobody else can take it, but it does not route\nuntil POST .../domains/{host}/verify proves control.\n\nA hostname we operate is refused to a non-vouched caller (those are assigned\nby the platform, never claimed), a host another site already holds is a 409,\nand a name the platform holds is a 400 for EVERY caller — a vouch skips the\nownership proof, never the host table's own invariant. Claims and binds are\nidempotent for the same\n(org, slug), and re-claiming returns the SAME token rather than invalidating a\nrecord the customer has already published. The edge cache-tag is flushed\nafterwards so a newly-verified host serves the current build immediately.\n\nScope: a validated principal is required (403 without one) and the site is\nresolved within that principal's org, so another tenant's slug is a 404.",
|
||||
"description": "Attaches one or more CUSTOM public hostnames to this org's site.\n\nBinding a host you do not own would let you shadow it at the edge, so which\noutcome you get depends on whether ownership is already established: a SuperAdmin\nvouches (the operator manages the customer's DNS, so its bind IS the proof) and\nbinds VERIFIED immediately; every other caller, INCLUDING an admin of the\ndeployment's own brand org, has the host CLAIMED as pending and gets the DNS\nchallenge back in `bound[].records`. A pending claim HOLDS the name so nobody\nelse can take it, but it does not route until POST .../domains/{host}/verify\nproves control.\n\nA hostname we operate is refused to a non-vouched caller (those are assigned\nby the platform, never claimed), a host another site already holds is a 409,\nand a name the platform holds is a 400 for EVERY caller — a vouch skips the\nownership proof, never the host table's own invariant. Claims and binds are\nidempotent for the same\n(org, slug), and re-claiming returns the SAME token rather than invalidating a\nrecord the customer has already published. The edge cache-tag is flushed\nafterwards so a newly-verified host serves the current build immediately.\n\nScope: a validated principal is required (403 without one) and the site is\nresolved within that principal's org, so another tenant's slug is a 404.",
|
||||
"tags": [
|
||||
"projects"
|
||||
],
|
||||
@@ -1741,6 +1741,10 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"key": {
|
||||
"description": "Key is the project's publishable ingest key, minted at create. It is the\nvalue the injected beacon carries and the ONE thing that attributes this\nsite's events; the static-builder reads it beside analytics.\n\nPublishable means it belongs in a page's source: it names a write scope and\nmints no principal, so it is returned in full rather than masked. Masking it\nwould only mean every caller needed a second endpoint to get the thing the\npage already ships.",
|
||||
"type": "string"
|
||||
},
|
||||
"lastPurgeAt": {
|
||||
"type": "integer"
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"paths": {
|
||||
"/v1/sync": {
|
||||
"get": {
|
||||
"operationId": "get_v1_sync",
|
||||
"operationId": "v1.get_sync",
|
||||
"summary": "List returns every sync link the caller's org has, each with its two endpoints, its direction and trigger policy, and the time it last reconciled.",
|
||||
"description": "List returns every sync link the caller's org has, each with its two endpoints, its\ndirection and trigger policy, and the time it last reconciled. Scoped to the\ncaller's own org — another tenant's links are structurally unreachable.",
|
||||
"tags": [
|
||||
@@ -38,7 +38,7 @@
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"operationId": "post_v1_sync",
|
||||
"operationId": "v1.post_sync",
|
||||
"summary": "Create declares a sync between two endpoints and returns it.",
|
||||
"description": "Create declares a sync between two endpoints and returns it. It is an UPSERT:\nre-declaring the same source and target updates that link rather than piling up\nduplicates, so a console that re-submits is safe. The org comes from the validated\nprincipal, never from the request, so a sync can only ever bind endpoints inside\nthe caller's own org. A git source must be an https clone URL on the provider's own\nhost with no embedded credentials; a target left empty is derived as a native\nrepository named after the source. With run=true the first reconcile is queued in\nthe background, so a large initial import never blocks this response.",
|
||||
"tags": [
|
||||
@@ -77,7 +77,7 @@
|
||||
},
|
||||
"/v1/sync/{id}": {
|
||||
"delete": {
|
||||
"operationId": "delete_v1_sync_id",
|
||||
"operationId": "v1.delete_sync_id",
|
||||
"summary": "Delete removes one sync and tears down the outbound mirror it derived, answering 204.",
|
||||
"description": "Delete removes one sync and tears down the outbound mirror it derived, answering\n204. The teardown is the point: without it an unsynced repository would keep\nforce-pushing to the upstream it is no longer linked to. Org-scoped, so another\ntenant's id is the same 404 an unknown id gives.",
|
||||
"tags": [
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
},
|
||||
"get": {
|
||||
"operationId": "get_v1_sync_id",
|
||||
"operationId": "v1.get_sync_id",
|
||||
"summary": "Get returns one sync by id.",
|
||||
"description": "Get returns one sync by id. It is org-scoped: an id belonging to another tenant is\nthe same 404 an unknown id gives, so a probe learns nothing about what exists.",
|
||||
"tags": [
|
||||
@@ -134,7 +134,7 @@
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "patch_v1_sync_id",
|
||||
"operationId": "v1.patch_sync_id",
|
||||
"summary": "Patch updates one sync's mutable policy — direction, trigger and actor — in place.",
|
||||
"description": "Patch updates one sync's mutable policy — direction, trigger and actor — in place.\nThe endpoints and the kind are immutable: re-pointing a sync is a delete and a\ncreate, so a link can never silently start syncing somewhere else. A field the\nrequest omits is left as it was. Changing the direction immediately reconciles the\nderived outbound mirror, so turning push off stops the upstream being written to\nrather than merely recording the intent.",
|
||||
"tags": [
|
||||
@@ -182,7 +182,7 @@
|
||||
},
|
||||
"/v1/sync/{id}/run": {
|
||||
"post": {
|
||||
"operationId": "post_v1_sync_id_run",
|
||||
"operationId": "v1.post_sync_id_run",
|
||||
"summary": "Run reconciles one sync now — the manual re-sync, and the initial import for a link created without run=true.",
|
||||
"description": "Run reconciles one sync now — the manual re-sync, and the initial import for a link\ncreated without run=true. The work is handed to a bounded background worker and the\ncall answers 202 immediately, so a large mirror-in never holds the request open;\nqueued=true means accepted, not finished.",
|
||||
"tags": [
|
||||
|
||||
@@ -381,8 +381,8 @@
|
||||
"/v1/team/transactor/api/v1/statistics": {
|
||||
"get": {
|
||||
"operationId": "get_v1_team_transactor_api_v1_statistics",
|
||||
"summary": "Statistics returns the transactor's live sessions for the workspace the caller's token names — the endpoint the front's workspace switcher and server panel poll on the transactor base.",
|
||||
"description": "Statistics returns the transactor's live sessions for the workspace the\ncaller's token names — the endpoint the front's workspace switcher and server\npanel poll on the transactor base. The token is verified exactly like the\nWebSocket upgrade is, and activeSessions carries ONLY that token's own\nworkspace, never another tenant's sessions. An invalid or expired token is\n401.",
|
||||
"summary": "Statistics returns the transactor's live sessions for the workspace the caller's credential names — the endpoint the front's workspace switcher and server panel poll on the transactor base.",
|
||||
"description": "Statistics returns the transactor's live sessions for the workspace the caller's\ncredential names — the endpoint the front's workspace switcher and server panel\npoll on the transactor base. `token` carries the same two lanes the socket's path\nsegment does: a workspace UUID names the workspace and is authorized against the\nmembership rows, an HS256 workspace token names it in its signed claims.\nactiveSessions carries ONLY that one workspace, never another tenant's sessions.\nAn unverifiable credential, or one the caller is no member under, is 401.",
|
||||
"tags": [
|
||||
"team"
|
||||
],
|
||||
@@ -415,8 +415,8 @@
|
||||
"/v1/team/transactor/statistics": {
|
||||
"get": {
|
||||
"operationId": "get_v1_team_transactor_statistics",
|
||||
"summary": "Statistics returns the transactor's live sessions for the workspace the caller's token names — the endpoint the front's workspace switcher and server panel poll on the transactor base.",
|
||||
"description": "Statistics returns the transactor's live sessions for the workspace the\ncaller's token names — the endpoint the front's workspace switcher and server\npanel poll on the transactor base. The token is verified exactly like the\nWebSocket upgrade is, and activeSessions carries ONLY that token's own\nworkspace, never another tenant's sessions. An invalid or expired token is\n401.",
|
||||
"summary": "Statistics returns the transactor's live sessions for the workspace the caller's credential names — the endpoint the front's workspace switcher and server panel poll on the transactor base.",
|
||||
"description": "Statistics returns the transactor's live sessions for the workspace the caller's\ncredential names — the endpoint the front's workspace switcher and server panel\npoll on the transactor base. `token` carries the same two lanes the socket's path\nsegment does: a workspace UUID names the workspace and is authorized against the\nmembership rows, an HS256 workspace token names it in its signed claims.\nactiveSessions carries ONLY that one workspace, never another tenant's sessions.\nAn unverifiable credential, or one the caller is no member under, is 401.",
|
||||
"tags": [
|
||||
"team"
|
||||
],
|
||||
@@ -450,7 +450,7 @@
|
||||
"get": {
|
||||
"operationId": "get_v1_team_transactor_by_token",
|
||||
"summary": "Open the workspace data-plane socket",
|
||||
"description": "Upgrades to the WebSocket the Team client runs an entire workspace over: every frame is a ZAP envelope wrapping one JSON-RPC message — findAll/findOne reads against the workspace's documents, tx writes that broadcast to the other live sessions, hello negotiating JSON rather than msgpack. The response is a protocol upgrade, so there is no body to read.\n\nTHE PATH SEGMENT IS THE CREDENTIAL. It is the workspace token selectWorkspace minted — bearer-equivalent, and sitting in a URL that proxies and access logs record, which is exactly why it expires in twelve hours and is re-minted on demand rather than being long-lived like the session token. It is decoded and verified (signature and expiry) BEFORE the upgrade, so a bad one is a 401 and never a socket that is accepted and then dropped, and it must carry both an account and a workspace claim.\n\nThe tenant is the token's SIGNED org claim and it keys every store path, so no header can name another workspace's data. The upgrade ALSO refuses a browser Origin outside the team surfaces with 403 — otherwise any page could open an authenticated socket with a token it lured out of a logged-in browser — while a request with no Origin at all is admitted, because that is what a non-browser client sends.\n\nOn connect the workspace's system spaces are seeded once and the roster is reconciled every time, so the org's human members and its bots are present as workspace people without a separate sync call.",
|
||||
"description": "Upgrades to the WebSocket the Team client runs an entire workspace over: every frame is a ZAP envelope wrapping one JSON-RPC message — findAll/findOne reads against the workspace's documents, tx writes that broadcast to the other live sessions, hello negotiating JSON rather than msgpack. The response is a protocol upgrade, so there is no body to read.\n\nTHE PATH SEGMENT IS THE CREDENTIAL. It is the workspace token selectWorkspace minted — bearer-equivalent, and sitting in a URL that proxies and access logs record, which is exactly why it expires in twelve hours and is re-minted on demand rather than being long-lived like the session token. It is decoded and verified (signature and expiry) BEFORE the upgrade, so a bad one is a 401 and never a socket that is accepted and then dropped, and it must carry both an account and a workspace claim. Nothing ambient authorizes this socket: a WebSocket is exempt from CORS, so a cookie-borne credential would make the Origin check the only access control on the whole data plane.\n\nThe tenant is the token's SIGNED org claim and it keys every store path, so no header can name another workspace's data. The upgrade ALSO refuses a browser Origin outside the team surfaces with 403 — otherwise any page could open an authenticated socket with a token it lured out of a logged-in browser — while a request with no Origin at all is admitted, because that is what a non-browser client sends.\n\nOn connect the workspace's system spaces are seeded once and the roster is reconciled every time, so the org's human members and its bots are present as workspace people without a separate sync call.",
|
||||
"tags": [
|
||||
"team"
|
||||
],
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "ID is the settlement id from the URL — the deterministic keccak(from|nonce)\nkey an x402 receipt is issued under (the `id` field of a Receipt, and the\nvalue of the X-Payment-Response header a paid request answers with).",
|
||||
"description": "ID is the settlement id from the URL — the deterministic keccak(from|nonce)\nkey an x402 receipt is issued under (the `id` field of a Receipt, and the\n`transaction` of the SettlementResponse on the PAYMENT-RESPONSE header a paid\nrequest answers with).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -65,6 +65,9 @@
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"network": {
|
||||
"type": "string"
|
||||
},
|
||||
"nonce": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user