← Alle Artikel
App-Entwicklung Business-Verantwortliche · · Von ObjectStack Team

Wie lässt man anonyme Besucher ein Formular in eine governte Business-App senden? Eine öffentliche Tür, fünf Felder breit

Seit v17 wird anonymer Datenzugriff verweigert. Die einzige offene Tür ist eine öffentliche Formularansicht: Das Recht kommt aus dem Formular — fünf Felder rein, eine Zeile angelegt, nur ihre ID zurück. Gemessen auf 18.3.0.

Wie lässt man anonyme Besucher ein Formular in eine governte Business-App senden? Eine öffentliche Tür, fünf Felder breit Wie lässt man anonyme Besucher ein Formular in eine governte Business-App senden? Eine öffentliche Tür, fünf Felder breit
  • Öffentliche Formulare
  • Web-to-Lead
  • Anonymer Zugriff

Kurzfassung. Seit v17 wird eine nicht authentifizierte Anfrage nach Objektdaten in einer ObjectStack-App mit 401 UNAUTHENTICATED abgewiesen — jedes Objekt, jeder Endpunkt, kein Opt-out. Die einzige Tür, die eine governte App der Öffentlichkeit offen lässt, ist eine öffentliche Formularansicht: Man deklariert ein Formular, setzt in seinem sharing-Block allowAnonymous: true, und die Runtime leitet die gesamte Berechtigung des Besuchers aus dieser Deklaration ab — einen Datensatz auf diesem einen Objekt anlegen, sonst nichts, und die Antwort ist die ID des neuen Datensatzes. Die Feldliste des Formulars ist die Sicherheitsgrenze. Das Web-to-Lead-Formular des CRM-Beispiels listet fünf Felder, also können fünf Felder ankommen; die übrigen acht Geschäftsfelder und jede Systemspalte können es nicht, was auch immer der Besucher schickt, und ein Feld, das das Formular als Pflichtfeld markiert, muss gefüllt sein, bevor irgendetwas geschrieben wird. Ich nenne das eine formförmige Berechtigung (form-shaped grant): eine Autorisierung, die zur Anfragezeit aus dem Formular berechnet wird — und deshalb nicht breiter konfiguriert werden kann als das Formular. Alles Folgende wurde auf der veröffentlichten @objectstack/cli 18.3.0 gemessen, der Version, die npm create objectstack@latest heute installiert — einschließlich der Frage, ob ein guest_portal-Berechtigungsset nötig ist. Es ist es nicht, und seit 18.0 sagt das auch das mitgelieferte Beispiel.

Das Problem, in den Worten der Doku

Der ObjectStack-Leitfaden zur öffentlichen Datenerfassung beginnt mit dem Szenario, so wie ein Anwender es formulieren würde:

Ich möchte, dass anonyme Besucher ein Formular — „Kontakt“, „Demo anfragen“, ein Aufnahmeformular — absenden, das einen Datensatz anlegt. Aber ich darf ihnen keinen Zugriff auf die Datenbasis geben, und sie dürfen nur die Felder auf dem Formular setzen können.

Jede Business-App hat so ein Formular: das Lead-Formular auf der Website, die Support-Aufnahme, die Demo-Anfrage, den Garantieantrag. Die üblichen Antworten kosten jede etwas. Eine „Gast“-Rolle mit Schreibrecht auf die Tabelle ist eine stehende Berechtigung, die niemand mehr liest, und sie driftet. Ein handgeschriebener Endpunkt umgeht das Berechtigungsmodell, das alles andere regelt. Ein externes Formular-Tool mit Webhook bedeutet, dass nun zwei Systeme das Schema halten — und eines davon ist nicht das, das die Reviewer anschauen.

Seit v17 steht die erste Antwort nicht einmal mehr zur Verfügung. Die Berechtigungs-Dokumentation hält den Grund nebenbei fest: „since v17 anonymous access to object data is always denied anyway — the requireAuth: false opt-out was retired“ (seit v17 wird anonymer Zugriff auf Objektdaten ohnehin immer verweigert — das Opt-out requireAuth: false wurde ausgemustert). Die Anleitung zum Self-Hosting macht daraus einen Prüfschritt — einen Objekt-Endpunkt ohne Anmeldedaten per curl aufrufen und auf der 401 mit maschinenlesbarem code bestehen — und behandelt eine 200 dort als Vorfall. Die ehrliche Form der Frage lautet also: Wenn die Datenebene für anonyme Aufrufer konstruktionsbedingt geschlossen ist, wie kommt ein öffentliches Formular überhaupt hindurch?

Ein öffentliches Formular ist eine Deklaration, keine Berechtigung

Das ist das gesamte Web-to-Lead-Formular des CRM-Beispiels, so wie es am Tag @objectstack/[email protected] ausgeliefert wird:

formViews: {
  web_to_lead: {
    type: 'simple',
    data: { provider: 'object', object: 'crm_lead' },
    sections: [
      {
        name: 'contact_us',
        label: 'Contact us',
        columns: 1,
        fields: [
          { field: 'name', required: true },
          { field: 'company' },
          { field: 'email', required: true },
          { field: 'phone' },
          { field: 'title' },
        ],
      },
    ],
    sharing: {
      enabled: true,
      allowAnonymous: true,
      publicLink: '/forms/contact-us',
    },
  },
},

Nichts darin erwähnt eine Berechtigung. Der sharing-Block öffnet die Tür: Sind enabled, allowAnonymous und ein publicLink gesetzt, registriert die Runtime zwei anonyme Endpunkte — GET /api/v1/forms/contact-us, der das aufgelöste Formular und das gefilterte Schema zurückgibt, und POST /api/v1/forms/contact-us/submit, der ein crm_lead einfügt. Die Einzeilen-Regel der Doku dafür, was der Besucher schreiben darf, lautet: „Only the fields listed in sections[].fields are accepted on submit; everything else is stripped server-side“ (beim Absenden werden nur die in sections[].fields gelisteten Felder akzeptiert; alles andere wird serverseitig entfernt). Der nächste Satz, seit 18.0 dabei, sagt, dass die Submit-Route „also enforces each entry’s required: true before anything is written“ (vor jedem Schreiben auch das required: true jedes Eintrags durchsetzt). Und die Begründung, warum es so gebaut ist, ist der Satz, zu dessen Prüfung dieser Beitrag existiert: „Authorization is derived from the declaration, not configured separately — so the grant can’t drift wider than the form“ (Die Autorisierung wird aus der Deklaration abgeleitet, nicht separat konfiguriert — deshalb kann die Berechtigung nicht breiter driften als das Formular).

Zwei Netze: Die Route behält nur die fünf deklarierten Felder, verwirft servergeführte Namen und weist ein leeres Pflichtfeld ab; die Datenschicht erlaubt nur Einfügen und Zurücklesen auf crm_lead und entfernt dieselben Namen erneut. Der Besucher bekommt die neue ID; Lesezugriffe antworten 401; ein zurückgezogenes Formular antwortet 404.

Mechanisch gibt es zwei Netze, und es lohnt sich, sie zu benennen, weil ein Reviewer jedes einzeln prüfen kann.

Das erste Netz ist die Route. Der Submit-Handler baut seine Allow-List aus sections[].fields des gefundenen Formulars und kopiert nur diese Schlüssel aus dem Request-Body. Vorher verwirft er per Name jeden Schlüssel aus PUBLIC_FORM_SERVER_MANAGED_FIELDS, einer Menge, die das Spec-Paket exportiert: id, owner_id, owning_business_unit_id, queue, organization_id, tenant_id, created_at, created_by, updated_at, updated_by, is_deleted, deleted_at und __search — dreizehn Namen, die laut Doku „never accepted from a public submission — not even when a section declares them“ sind (aus einer öffentlichen Übermittlung nie akzeptiert — nicht einmal, wenn ein Abschnitt sie deklariert). (queue ist der jüngste; 18.2 hatte zwölf.) Ein Formular ganz ohne Felder wird mit 400 abgewiesen, statt alles anzunehmen, was ankommt. Dann prüft die Route das Formular selbst, bevor irgendetwas geschrieben wird: Ein Feld, das das Formular als required markiert und immer anzeigt, muss einen Wert tragen (seit 18.0), ein Wert in einem Dateifeld wird abgewiesen (seit 18.2), ebenso ein Wert außerhalb der options-Teilmenge, die ein Formulareintrag anbietet (seit 18.3) — alles in einem einzigen 400 VALIDATION_FAILED, das jedes Feld benennt.

Das zweite Netz ist die Datenschicht. Die Route hängt an den Schreibvorgang ein anfragebezogenes publicFormGrant: { object: 'crm_lead' }, und das Security-Plugin honoriert genau das: insert, dazu find, findOne und count für das unmittelbare Zurücklesen, nur auf diesem Objekt. Bevor es das Einfügen zulässt, entfernt es dieselben dreizehn Namen ein zweites Mal — selbst wenn also ein Formular owner_id in einem Abschnitt deklarierte oder Routenfilter und Datenschicht eines Tages auseinanderbearbeitet würden, könnte ein Besucher weder Eigentümerschaft noch Mandantenzuordnung fälschen. Die Berechtigung ist die Form des Formulars, zur Anfragezeit gelesen. Es gibt keine Zeile in einer Berechtigungstabelle, die sagt „anonym darf Leads schreiben“, weil es nichts zu konfigurieren gibt. Das Zurückgelesene erreicht die Route, nicht den Besucher: Die Route antwortet mit der erzeugten ID und mit nichts anderem, was das Einfügen gespeichert hat.

Das Protokoll, auf 18.3.0

Ich habe mit npm create objectstack@latest eine frische App erzeugt — @objectstack/cli 18.3.0 — und vom Tag @objectstack/[email protected] das Objekt crm_lead des CRM-Beispiels (samt den zwei Objekten, die seine Lookups nennen), seine lead.view.ts mit dem Web-to-Lead-Formular und sein guest_portal-Berechtigungsset hineinkopiert. Dann objectstack dev, das die Konfiguration beim Start kompiliert und auf einer leeren Datenbank ein Admin-Konto anlegt. Jede Antwort unten ist echt und nur der Länge wegen gekürzt.

1. Die Tür beschreibt sich selbst — fünf Felder breit. Ohne Anmeldedaten:

curl -s http://localhost:49900/api/v1/forms/contact-us
{ "slug": "contact-us", "object": "crm_lead",
  "form": { "sections": [ { "name": "contact_us", "label": "Contact us", "fields": [
      { "field": "name", "required": true }, { "field": "company" },
      { "field": "email", "required": true }, { "field": "phone" }, { "field": "title" } ] } ],
    "sharing": { "enabled": true, "publicLink": "/forms/contact-us", "allowAnonymous": true } },
  "objectSchema": { "name": "crm_lead", "label": "Lead", "fields": {
      "name":    { "label": "Lead Name", "type": "text",  "required": true, "maxLength": 200 },
      "email":   { "label": "Email",     "type": "email", "required": false },
      "phone":   { "label": "Phone",     "type": "phone", "required": false },
      "company": { "label": "Company",   "type": "text",  "required": false, "maxLength": 200 },
      "title":   { "label": "Title",     "type": "text",  "required": false, "maxLength": 120 } } } }

Das objectSchema trägt genau die fünf freigegebenen Felder, jedes mit Label, Typ und Grenzen, die ein Frontend zum Zeichnen braucht. crm_lead deklariert in seiner Objektdefinition dreizehn Geschäftsfelder; das öffentliche Schema zeigt fünf und sagt nichts über status, lead_score, assigned_to, account, notes oder den Rest. Der Besucher kann nicht einmal erfahren, was er versuchen könnte. Die Labels folgen der Sprache des Besuchers: Mit dem chinesischen Übersetzungsbündel des CRM geladen lieferte derselbe Aufruf mit Accept-Language: zh-CN die Abschnittsüberschrift, das Objektlabel und alle fünf Feldlabels auf Chinesisch, unter Vary: Accept-Language.

2. Eine saubere Übermittlung legt eine Zeile an, und die Antwort ist ihre ID.

curl -s -X POST http://localhost:49900/api/v1/forms/contact-us/submit \
  -H 'content-type: application/json' \
  -d '{"name":"Ada Lovelace","email":"[email protected]"}'
HTTP 201
{ "id": "dgYZFAvljruviVy9" }

Das ist die ganze Antwort: die ID des neuen Datensatzes und nichts, was das Einfügen gespeichert hat. Als Admin zurückgelesen zeigt die Zeile, was der Besucher nicht geschickt hat:

curl -s -b admin.cookies http://localhost:49900/api/v1/data/crm_lead/dgYZFAvljruviVy9
{ "id": "dgYZFAvljruviVy9", "name": "Ada Lovelace", "email": "[email protected]",
  "status": "new", "source": "web", "lead_score": 0, "owner_id": null, "queue": null,
  "organization_id": "org_mw8n9qe618ypwdpk", "created_by": null }

status: "new" wurde nicht gesendet; es ist der Default des Objekts auf dem Schreibpfad, den das Lead-Objekt so begründet: „a create that omits status (e.g. the public Web-to-Lead form, which doesn’t collect it) is stored as ‘new’, so it satisfies required“ (ein Anlegen ohne status — etwa über das öffentliche Web-to-Lead-Formular, das ihn nicht erfasst — wird als ‘new’ gespeichert und erfüllt so required). source: "web" und lead_score: 0 sind ebenfalls Defaults. Auf 17.7.0 gab das 201 diese ganze Zeile an den anonymen Aufrufer zurück. Ich habe das gemeldet, und seit 18.0 ist die Antwort allein die ID (objectstack-Issue #22437, behoben durch PR #22462).

3. Eine feindselige Übermittlung legt dieselbe Zeile an. Sechs Schlüssel gesendet; zwei stehen im Formular:

curl -s -X POST http://localhost:49900/api/v1/forms/contact-us/submit \
  -H 'content-type: application/json' \
  -d '{"name":"Mallory","email":"[email protected]",
       "status":"converted","lead_score":100,
       "owner_id":"attacker","organization_id":"org_other"}'
HTTP 201
{ "id": "R2Wkp0koxTAw53tN" }

Die Antwort hat dieselbe Form wie zuvor und sagt deshalb nichts darüber, was behalten wurde. Das Zurücklesen dieser ID als Admin tut es:

{ "id": "R2Wkp0koxTAw53tN", "name": "Mallory", "email": "[email protected]",
  "status": "new", "lead_score": 0, "owner_id": null, "queue": null,
  "organization_id": "org_mw8n9qe618ypwdpk" }

Die zwei freigegebenen Felder kamen an, die zwei Geschäftsfelder außerhalb des Formulars wurden verworfen und die Defaults des Objekts griffen, und die zwei Systemanker wurden verworfen und dann vom Server gestempelt — organization_id ist die eigene Organisation des Deployments, nicht org_other, und owner_id ist null. Bei einem zweiten Start habe ich demselben Body queue, id und created_by hinzugefügt; die gespeicherte Zeile hatte eine vom Server erzeugte ID und null in den beiden anderen. Das 201 sagt nicht, welche Schlüssel abgewiesen wurden; die Route verwirft sie kommentarlos.

Gesendeter SchlüsselIm Formular?Gespeichert als (Admin-Zurücklesen)Warum
name, emailjawie gesendetfreigegeben
status: "converted"nein"new"nicht in sections[].fields; Objekt-Default greift
lead_score: 100nein0nicht in sections[].fields; Objekt-Default greift
owner_id: "attacker"neinnullservergeführter Name, in beiden Netzen verweigert
organization_id: "org_other"neinder des Serversservergeführter Name; von der Runtime gestempelt
queue, id, created_by (zweiter Start)neinnull, eine vom Server erzeugte ID, nullservergeführte Namen

4. Ein fehlendes Pflichtfeld wird vor dem Schreiben abgewiesen. Das Formular markiert email als Pflichtfeld; das Objekt nicht.

curl -s -X POST http://localhost:49900/api/v1/forms/contact-us/submit \
  -H 'content-type: application/json' \
  -d '{"name":"No Email"}'
HTTP 400
{ "error": "Email is required", "code": "VALIDATION_FAILED",
  "fields": [ { "field": "email", "code": "required", "message": "Email is required", "label": "Email" } ] }

Eine leere " "-E-Mail bekam dieselbe Antwort, und ein leerer Body nannte beide Felder in einem Umschlag ("Lead Name is required; Email is required"). Auf 17.7.0 antwortete derselbe POST mit 201 und email: null, weil auf diesem Pfad nur das required des Objekts durchgesetzt wurde. Auch das habe ich gemeldet, und seit 18.0 setzt die Route zuerst das eigene required des Formulars durch (objectstack-Issue #24131, PR #24161).

5. Die Tür öffnet sich nicht in die andere Richtung. Derselbe anonyme Client, jetzt lesend:

curl -s -w '\n%{http_code}\n' http://localhost:49900/api/v1/data/crm_lead
curl -s -w '\n%{http_code}\n' http://localhost:49900/api/v1/data/crm_lead/R2Wkp0koxTAw53tN
{"error":"UNAUTHENTICATED","code":"UNAUTHENTICATED","message":"Authentication is required to access this endpoint."}
401

Beide antworten 401 — die Liste und die einzelne Zeile, die der Besucher eine Sekunde zuvor angelegt hat. Der Absender sieht den gespeicherten Datensatz überhaupt nicht: Das 201 trägt seine ID, und die ID öffnet nichts. „The public path only ever inserts; it never lists“ (Der öffentliche Pfad fügt immer nur ein; er listet nie).

6. Das Zurückziehen des Formulars schließt beide Endpunkte. Den publicLink behalten, enabled: false setzen, objectstack dev neu starten:

GET  /api/v1/forms/contact-us         → 404 {"code":"FORM_NOT_FOUND","error":"No public form configured at /forms/contact-us"}
POST /api/v1/forms/contact-us/submit  → 404 {"code":"FORM_NOT_FOUND","error":"No public form configured at /forms/contact-us"}

Die als Admin gelesene Lead-Anzahl war vor und nach diesen beiden Aufrufen dieselbe: Es wurde nichts angelegt. Fünf Statuscodes erzählen die ganze Geschichte der Tür: 201, 201, 400, 401, 404.

Die Frage nach dem Gastprofil, upstream geklärt

Auf 17.7.0 widersprachen sich Dokumentation und mitgeliefertes Beispiel darin, was den Schreibvorgang autorisiert. Der Leitfaden sagte, wie er es heute noch tut: „Declare a public form view; the submit route derives a narrow authorization from that declaration. No guest profile, no broad grant“ (Deklariere eine öffentliche Formularansicht; die Submit-Route leitet daraus eine enge Autorisierung ab. Kein guest-Profil, keine breite Berechtigung). Das CRM-Beispiel lieferte ein Berechtigungsset namens guest_portal mit allowCreate: true auf crm_lead mit, sein Kommentar sagte, dieses Profil „authorizes the write“ (autorisiere den Schreibvorgang), und der Docstring der Route warnte, ohne eines falle die Middleware „falls open“ (offen aus). Ich habe das Set aus dem 17.7.0-CRM entfernt, die Sonden erneut laufen lassen, identische Antworten bekommen und den Widerspruch gemeldet (objectstack-Issue #24130).

Das wurde in 18.0 behoben (PR #24237). Am Tag 18.3.0 lautet der Kommentar über dem Set „It does NOT authorize the public Web-to-Lead form (lead.view.ts web_to_lead). That form’s own declaration does“ (Es autorisiert das öffentliche Web-to-Lead-Formular NICHT. Das tut die eigene Deklaration dieses Formulars), und er endet mit „the submit answers the created id only“ (das Absenden antwortet nur mit der erzeugten ID). Der Kommentar des Formulars selbst sagt „No permission set is needed, GuestPortalProfile included“ (Kein Berechtigungsset ist nötig, GuestPortalProfile eingeschlossen). Der Leitfaden hat einen Absatz bekommen, der mit „The submit’s execution context also names a guest_portal permission set. That name authorizes nothing“ beginnt (Der Ausführungskontext des Absendens nennt auch ein guest_portal-Berechtigungsset. Dieser Name autorisiert nichts), und der „falls open“-Docstring ist verschwunden. Das Beispiel behält das Set, weil ADR-0138 es als Referenz für den Namen festhält, den der Submit-Kontext anfordert.

Ich habe den Vergleich auf 18.3.0 trotzdem wiederholt: das Gerüst mit dem Set und noch einmal mit permissions: [], wobei das kompilierte Artefakt gar kein Berechtigungsset mehr enthielt.

SondeMit guest_portalOhne guest_portal
POST …/submit mit name, email201 { id }, Zeile angelegt201 { id }, Zeile angelegt
POST …/submit mit eingeschmuggeltem status, lead_score, owner_id, organization_id, queue201 { id }; zurückgelesen status: "new", lead_score: 0, owner_id: null, queue: null, organization_id des Serversidentisch
POST …/submit ohne email400 VALIDATION_FAILED, email requiredidentisch
anonymes GET /api/v1/data/crm_lead401 UNAUTHENTICATED401 UNAUTHENTICATED
anonymes GET der angelegten Zeile401 UNAUTHENTICATED401 UNAUTHENTICATED

Identisch, Zeile für Zeile, wie auf 17.7.0. Was der Name noch erreicht, ist eng: Ein als guest_portal registriertes Set liefert die Feldberechtigungen, die der Ergebnis-Maskierer der Berechtigung auf den an die Route zurückgegebenen Datensatz anwendet — einen Datensatz, den der Besucher nicht mehr sieht. Der praktische Rat bleibt also: Füge kein Gastprofil hinzu, damit ein öffentliches Formular funktioniert. Es fügt deiner Zugriffsmatrix eine stehende Berechtigung hinzu, die die öffentliche Tür nie konsultiert. Wenn deine App einen Snapshot der Zugriffsmatrix committet, wie das vollständige CRM, ist das Hinzufügen oder Entfernen eines solchen Sets eine Änderung, die der Build dich prüfen lässt; dieses Gate ist ein eigenes Thema.

Eine Stelle, an der Beispiel und Leitfaden weiterhin auseinandergehen: Schritt drei des Leitfadens lautet „Set sharingModel: 'private' on the object so submissions are staff-scoped after creation“ (Setze sharingModel: 'private' auf dem Objekt, damit Übermittlungen nach dem Anlegen nur für Mitarbeitende sichtbar sind), und das Lead-Objekt des CRM ist bewusst public_read_write, mit einem Kommentar, der erklärt, dass die Demo absichtlich organisationsweit geteilt ist. Das schwächt die Tür nicht — der organisationsweite Default regelt, was angemeldete Benutzer von den Zeilen der anderen sehen, und der anonyme Aufrufer wird abgewiesen, bevor irgendetwas davon konsultiert wird —, aber für dein eigenes Erfassungsobjekt ist es ein Rat, dem es sich zu folgen lohnt.

Was diese Tür nicht tut

  • Standardmäßig keine Ratenbegrenzung, kein Captcha, keine Spam-Abwehr. Auf dem Standardgerüst antworteten 40 Übermittlungen eines Clients in 0,89 Sekunden alle mit 201. Die Runtime bringt einen Begrenzer mit, serverweit und standardmäßig aus: Mit server: { security: { rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 10 } } } im Stack bekam derselbe Client neun 201 und dann 429 RATE_LIMIT_EXCEEDED mit einem Retry-After-Header; sein vorheriges GET des Formulars hatte aus demselben Budget geschöpft. Er misst jede Route und zählt ohne Sitzung nach der Adresse des Aufrufers, sodass er hinter einem Reverse Proxy die Adresse des Proxys zählt, es sei denn, du deklarierst server.trustProxy — was nur sicher ist, wenn dieser Proxy die Forwarding-Header überschreibt. Er ist ein Budget, kein Bot-Schutz: Setze den üblichen Edge-Schutz vor /api/v1/forms/*.
  • Das required des Formulars deckt nur ab, was das Formular immer zeigt. Ein Eintrag mit hidden: true oder mit einem visibleWhen auf dem Eintrag oder seinem Abschnitt wird beim Absenden nicht geprüft, weil serverseitig nichts das visibleWhen eines Formulars auswertet. Muss ein solches Feld vorhanden sein, mach es auf dem Objekt zur Pflicht oder prüfe es in einem Hook.
  • Die Antwort sagt dem Besucher fast nichts, mit Absicht. Das 201 trägt die neue ID und keine Auskunft darüber, was behalten oder verworfen wurde. Ein Formular, das eine Referenz zeigen muss, kann in seinem Dankes-Panel ein von der Engine erzeugtes Feld, etwa eine autonumber-Fallnummer, als {{record.case_number}} nennen; laut Leitfaden trägt die Antwort diesen Wert dann unter fields, und os validate sowie das Publish-Gate weisen jedes andere Feld ab. Das CRM-Formular nennt keines, also habe ich diesen Zweig nicht gemessen. Auf 17.7.0 trug das Echo auch, was ein beforeInsert- oder afterInsert-Hook gestempelt hatte, und dieser Beitrag riet, Hooks mit Datensatz-Lookups von öffentlichen Erfassungsobjekten fernzuhalten; seit 18.0 filtert die Route, was dem Besucher gezeigt wird, und dieser Rat entfällt.
  • Der Eigentümer ist niemand. owner_id bleibt bei einer öffentlichen Übermittlung null; das Security-Plugin lässt es „for object hooks / the first-admin bootstrap to assign“ (für Objekt-Hooks bzw. den Erst-Admin-Bootstrap zum Zuweisen). Wenn deine Pipeline Leads zuweist, tu das in einem Hook oder Flow — erwarte nicht, dass die Zeile einer Person gehört.
  • Ein Zurückziehen braucht einen Neustart. Seit 18.2 kompiliert objectstack dev die Konfiguration standardmäßig beim Start, und ein einfacher Neustart hat das Formular hier geschlossen. Auf 17.7.0 änderte mein erster Versuch nichts, weil ein --no-watch-Start den vorherigen Build auslieferte. Eine agentengetriebene Schleife, die nur Statuscodes liest, sollte das 404 prüfen, statt es anzunehmen.
  • Das Zurückziehen über Metadatenebenen hinweg hat Ränder, die dieser Beitrag nicht abgegangen ist. Die gespeicherte Kopie einer Ansicht in einer Organisation und von Paketen ausgelieferte Formulare folgen im Leitfaden eigenen Regeln. Der hier getestete einzelne Schalter ist der, den die meisten Apps brauchen.

Fünf Fragen eines Reviewers

Ob das Formular von einem Menschen oder einem Agenten geschrieben wurde — das Review ist dasselbe, und es ist kurz, weil die Berechtigung das Formular ist.

  1. Welche Formulare sind öffentlich? Jede Formularansicht, bei der sharing.enabled, sharing.allowAnonymous und ein publicLink gesetzt sind, ist ein anonymer Endpunkt. Das kompilierte Artefakt listet sie auf; es gibt keinen anderen Ort zum Nachsehen.
  2. Was steht in sections[].fields? Diese Liste, minus die dreizehn servergeführten Namen, ist die öffentliche Schreibfläche. Lies sie, wie du eine Firewall-Regel lesen würdest.
  3. Füllt das Objekt, was das Formular auslässt? Jedes Pflichtfeld, das das Formular nicht erfasst, braucht einen Default auf Objektebene — status im Beispiel —, sonst schlägt die Übermittlung fehl. Ein Feld, das das Formular zeigt und als required markiert, wird an der Tür durchgesetzt; eines, das es verbirgt oder nur bedingt zeigt, braucht sein required auf dem Objekt.
  4. Gibt es irgendwo eine stehende anonyme Berechtigung? Ein Berechtigungsset, das an eine guest- oder everyone-Position gebunden ist, oder ein guest_portal-Set, das hinzugefügt wurde, „damit das Formular funktioniert“, ist unnötig und erweitert nichts, was die Tür nutzt. Behandle sein Vorhandensein als Frage.
  5. Was steht vor der Tür? Bot-Schutz ist Sache des Deployments. Der eigene Begrenzer der Runtime ist ein serverweites Budget, und er ist aus, bis du ihn deklarierst.

Das ist dasselbe Argument über den Ort der Durchsetzung wie in wo Agentenberechtigungen durchgesetzt werden, angewandt auf einen Aufrufer ganz ohne Identität: Die Prüfung läuft in der Route und der Datenschicht, nicht in der UI, die die Felder zufällig verbirgt. Und es ist die Antwort auf das Review-Problem, das der Lovable-Beitrag beschreibt — der verantwortliche Eigentümer kann die fünfzeilige Feldliste lesen und abzeichnen, was bei einer generierten Policy nicht möglich ist. Der Glossareintrag zum Berechtigungsmodell hat die allgemeine Form; diese Tür ist die eine bewusste Ausnahme von „standardmäßig verweigern“, und sie hat die Form des Formulars.

Richte deinen Agenten darauf aus

Wenn ein Agent deine App schreibt, ist das, was du ihm gibst, nicht dieser Artikel, sondern die Regel: Ein öffentliches Formular ist ein formViews-Eintrag mit einem sharing-Block, die Feldliste ist die Grenze, das required des Formulars wird an der Tür geprüft, das Objekt besitzt die Defaults, und kein Berechtigungsset ist beteiligt. Der Leitfaden zur Konfiguration eines Coding-Agenten für ObjectStack behandelt, wie diese Regel den Agenten erreicht — die Regeldatei, der MCP-Endpunkt, den eine laufende App bereitstellt, und die Spec. npm create objectstack@latest installiert 18.3.0, die Version, auf der dieses Protokoll lief.

Zum Ausführen liefert das ObjectStack-Repository ObjectOS Community — das Image ghcr.io/objectstack-ai/objectstack, os start und die Console, kostenlos und selbst gehostet — während ObjectOS Cloud die gehostete Edition ist und ObjectOS Enterprise die selbst verwaltete, lizenzierte.