@pippit-dev/cli 1.0.37 → 1.0.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -3
- package/checksums.txt +6 -6
- package/cmd/auth/auth.go +4 -3
- package/cmd/marketing.go +5 -4
- package/cmd/marketing_test.go +58 -0
- package/cmd/root.go +40 -14
- package/cmd/root_test.go +63 -0
- package/cmd/source_test.go +2 -3
- package/cmd/submit_run_test.go +20 -81
- package/cmd/update/update.go +36 -10
- package/cmd/update/update_test.go +73 -3
- package/dist/checksums.txt +6 -6
- package/dist/xyq-canvas-command-runtime.cjs +14 -14
- package/dist/xyq-canvas-command-runtime.cjs.LEGAL.txt +0 -206
- package/dist/xyq-canvas-command-runtime.cjs.sha256 +2 -2
- package/internal/adapter/adapter.go +73 -0
- package/internal/adapter/ppe.go +36 -0
- package/internal/adaptercli/commands.go +27 -0
- package/internal/adaptercli/commands_test.go +41 -0
- package/internal/auth/manager.go +25 -13
- package/internal/canvas/apply.go +3 -0
- package/internal/canvas/media_identity.go +300 -0
- package/internal/canvas/media_identity_test.go +160 -0
- package/internal/canvas/upload.go +30 -1
- package/internal/commandnames/names.go +9 -0
- package/internal/common/adapter_test.go +74 -0
- package/internal/common/client.go +37 -12
- package/internal/config/config.go +41 -0
- package/internal/config/config_test.go +41 -1
- package/internal/generate_video/generate_video.go +1 -0
- package/internal/generate_video/generate_video_test.go +25 -3
- package/internal/models/models.go +6 -2
- package/internal/models/models_test.go +7 -0
- package/package.json +2 -2
- package/scripts/canvas-command.js +4 -2
- package/scripts/install-wizard.js +3 -3
- package/scripts/install.js +1 -1
- package/scripts/telemetry.js +16 -0
- package/skills/short-drama/SKILL.md +6 -3
- package/skills/short-drama/commands/download-result.md +9 -3
- package/skills/short-drama/commands/get-thread.md +2 -0
- package/skills/short-drama/commands/list-thread-file.md +12 -6
- package/skills/short-drama/commands/submit-run.md +3 -1
- package/skills/short-drama/examples/create-and-deliver.md +2 -2
- package/skills/short-drama/examples/reference-and-continue.md +20 -1
- package/skills/short-drama/workflows/creation.md +28 -14
- package/skills/short-drama/workflows/poll-and-deliver.md +27 -9
- package/skills/xyq-nest-skill/commands/canvas-assets.md +9 -1
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
package canvas
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"strings"
|
|
8
|
+
|
|
9
|
+
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
var canvasMediaAssetTypes = map[string]int{"image": 1, "video": 2, "audio": 3}
|
|
13
|
+
|
|
14
|
+
func mediaIdentityError(nodeID, field, reason string) error {
|
|
15
|
+
return fmt.Errorf("INVALID_MEDIA_IDENTITY asset %q at %s: %s", nodeID, field, reason)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func identityField(values map[string]json.RawMessage, name string) (json.RawMessage, error) {
|
|
19
|
+
var result json.RawMessage
|
|
20
|
+
for key, value := range values {
|
|
21
|
+
if !strings.EqualFold(key, name) {
|
|
22
|
+
continue
|
|
23
|
+
}
|
|
24
|
+
if result != nil {
|
|
25
|
+
return nil, fmt.Errorf("duplicate %s fields are ambiguous", name)
|
|
26
|
+
}
|
|
27
|
+
result = value
|
|
28
|
+
}
|
|
29
|
+
return result, nil
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func identityString(values map[string]json.RawMessage, name string) (string, error) {
|
|
33
|
+
raw, err := identityField(values, name)
|
|
34
|
+
if err != nil {
|
|
35
|
+
return "", err
|
|
36
|
+
}
|
|
37
|
+
if raw == nil || string(raw) == "null" {
|
|
38
|
+
return "", nil
|
|
39
|
+
}
|
|
40
|
+
var value string
|
|
41
|
+
if err := json.Unmarshal(raw, &value); err != nil {
|
|
42
|
+
return "", fmt.Errorf("%s must be a JSON string", name)
|
|
43
|
+
}
|
|
44
|
+
return strings.TrimSpace(value), nil
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func mediaSection(raw json.RawMessage, kind string) (map[string]json.RawMessage, error) {
|
|
48
|
+
var asset map[string]json.RawMessage
|
|
49
|
+
if err := json.Unmarshal(raw, &asset); err != nil {
|
|
50
|
+
return nil, err
|
|
51
|
+
}
|
|
52
|
+
// Native AgentAsset type, not preview sections: videos may also contain Image.
|
|
53
|
+
expectedType := canvasMediaAssetTypes[kind]
|
|
54
|
+
if typeRaw, ok := rawField(asset, "AssetType", "asset_type"); ok {
|
|
55
|
+
var actual int
|
|
56
|
+
if err := json.Unmarshal(typeRaw, &actual); err != nil || actual != expectedType {
|
|
57
|
+
return nil, fmt.Errorf("expected %s material", kind)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
sectionRaw, ok := rawField(asset, strings.ToUpper(kind[:1])+kind[1:], kind)
|
|
61
|
+
if !ok {
|
|
62
|
+
return nil, fmt.Errorf("expected %s material", kind)
|
|
63
|
+
}
|
|
64
|
+
var section map[string]json.RawMessage
|
|
65
|
+
if err := json.Unmarshal(sectionRaw, §ion); err != nil || section == nil {
|
|
66
|
+
return nil, fmt.Errorf("invalid %s material section", kind)
|
|
67
|
+
}
|
|
68
|
+
return section, nil
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func cloudMediaID(raw json.RawMessage, kind string) (string, error) {
|
|
72
|
+
section, err := mediaSection(raw, kind)
|
|
73
|
+
if err != nil {
|
|
74
|
+
return "", err
|
|
75
|
+
}
|
|
76
|
+
id, err := optionalStringField(section, "asset_id", "assetId", "AssetID")
|
|
77
|
+
if err != nil {
|
|
78
|
+
return "", err
|
|
79
|
+
}
|
|
80
|
+
if id == "" {
|
|
81
|
+
return "", fmt.Errorf("media ID is not ready; query the material again")
|
|
82
|
+
}
|
|
83
|
+
pippitID, err := assetIDFromRaw(raw)
|
|
84
|
+
if err != nil {
|
|
85
|
+
return "", err
|
|
86
|
+
}
|
|
87
|
+
if id == pippitID {
|
|
88
|
+
return "", fmt.Errorf("cloud media ID cannot be the Pippit material ID")
|
|
89
|
+
}
|
|
90
|
+
return id, nil
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func touchesMediaIdentity(path string) bool {
|
|
94
|
+
switch strings.ToLower(path) {
|
|
95
|
+
case "", "/type", "/content", "/content/assetid", "/content/pippitassetid":
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func decodeNodeAsset(raw json.RawMessage) (map[string]json.RawMessage, error) {
|
|
102
|
+
var record map[string]json.RawMessage
|
|
103
|
+
if err := json.Unmarshal(raw, &record); err != nil {
|
|
104
|
+
return nil, err
|
|
105
|
+
}
|
|
106
|
+
textRaw, ok := rawField(record, "TextInfo", "Text", "textInfo", "text")
|
|
107
|
+
if !ok {
|
|
108
|
+
return nil, fmt.Errorf("node asset has no inline text content")
|
|
109
|
+
}
|
|
110
|
+
var text map[string]json.RawMessage
|
|
111
|
+
if err := json.Unmarshal(textRaw, &text); err != nil {
|
|
112
|
+
return nil, err
|
|
113
|
+
}
|
|
114
|
+
content, ok := rawField(text, "Content", "content")
|
|
115
|
+
if !ok {
|
|
116
|
+
return nil, fmt.Errorf("node asset has no inline text content")
|
|
117
|
+
}
|
|
118
|
+
var value string
|
|
119
|
+
if json.Unmarshal(content, &value) == nil {
|
|
120
|
+
content = json.RawMessage(value)
|
|
121
|
+
}
|
|
122
|
+
var node map[string]json.RawMessage
|
|
123
|
+
if err := json.Unmarshal(content, &node); err != nil || node == nil {
|
|
124
|
+
return nil, fmt.Errorf("invalid node content")
|
|
125
|
+
}
|
|
126
|
+
return node, nil
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Only identity paths and their parents are projected. Other fields stay in the
|
|
130
|
+
// original request, so validation never reconstructs or rewrites persisted data.
|
|
131
|
+
func projectIdentityPatch(node map[string]json.RawMessage, patch PatchEntry) (map[string]json.RawMessage, error) {
|
|
132
|
+
if patch.Path == "" {
|
|
133
|
+
if patch.Op == "remove" {
|
|
134
|
+
return nil, nil
|
|
135
|
+
}
|
|
136
|
+
var value map[string]json.RawMessage
|
|
137
|
+
if err := json.Unmarshal(patch.Value, &value); err != nil {
|
|
138
|
+
return nil, err
|
|
139
|
+
}
|
|
140
|
+
return value, nil
|
|
141
|
+
}
|
|
142
|
+
if node == nil {
|
|
143
|
+
return nil, fmt.Errorf("cannot validate a patch without its node base")
|
|
144
|
+
}
|
|
145
|
+
parts := strings.Split(strings.TrimPrefix(patch.Path, "/"), "/")
|
|
146
|
+
key := parts[0]
|
|
147
|
+
if len(parts) == 1 {
|
|
148
|
+
if patch.Op == "remove" {
|
|
149
|
+
delete(node, key)
|
|
150
|
+
} else {
|
|
151
|
+
node[key] = patch.Value
|
|
152
|
+
}
|
|
153
|
+
return node, nil
|
|
154
|
+
}
|
|
155
|
+
var content map[string]json.RawMessage
|
|
156
|
+
if err := json.Unmarshal(node[key], &content); err != nil || content == nil {
|
|
157
|
+
return nil, fmt.Errorf("cannot validate a media identity without its content base")
|
|
158
|
+
}
|
|
159
|
+
if patch.Op == "remove" {
|
|
160
|
+
delete(content, parts[1])
|
|
161
|
+
} else {
|
|
162
|
+
content[parts[1]] = patch.Value
|
|
163
|
+
}
|
|
164
|
+
value, err := json.Marshal(content)
|
|
165
|
+
if err != nil {
|
|
166
|
+
return nil, err
|
|
167
|
+
}
|
|
168
|
+
node[key] = value
|
|
169
|
+
return node, nil
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func readNodeIdentity(node map[string]json.RawMessage) (kind, pippitID, mediaID string, err error) {
|
|
173
|
+
if node == nil {
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
kind, err = identityString(node, "type")
|
|
177
|
+
if err != nil {
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
switch kind {
|
|
181
|
+
case "biz/image", "biz/video", "biz/audio":
|
|
182
|
+
kind = strings.TrimPrefix(kind, "biz/")
|
|
183
|
+
default:
|
|
184
|
+
kind = ""
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
contentRaw, fieldErr := identityField(node, "content")
|
|
188
|
+
if fieldErr != nil {
|
|
189
|
+
err = fieldErr
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
var content map[string]json.RawMessage
|
|
193
|
+
if err = json.Unmarshal(contentRaw, &content); err != nil {
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
pippitID, err = identityString(content, "pippitAssetId")
|
|
197
|
+
if err != nil {
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
mediaID, err = identityString(content, "assetId")
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
func validateApplyMediaIdentities(ctx context.Context, request ApplyRequest, runner *common.Runner) error {
|
|
205
|
+
patches := request.Transactions[0].Patches
|
|
206
|
+
affected := make(map[string]bool)
|
|
207
|
+
var ordered []string
|
|
208
|
+
var toQuery []string
|
|
209
|
+
for _, patch := range patches {
|
|
210
|
+
if touchesMediaIdentity(patch.Path) && !affected[patch.AssetID] {
|
|
211
|
+
affected[patch.AssetID] = true
|
|
212
|
+
ordered = append(ordered, patch.AssetID)
|
|
213
|
+
if patch.Path != "" {
|
|
214
|
+
toQuery = append(toQuery, patch.AssetID)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
nodes := make(map[string]map[string]json.RawMessage)
|
|
219
|
+
type identity struct{ nodeID, kind, pippitID, mediaID string }
|
|
220
|
+
previous := make(map[string]identity)
|
|
221
|
+
if len(toQuery) > 0 {
|
|
222
|
+
result, err := queryAssets(ctx, toQuery, true, runner)
|
|
223
|
+
if err != nil {
|
|
224
|
+
return fmt.Errorf("canvas media identity preflight failed: %w", err)
|
|
225
|
+
}
|
|
226
|
+
for _, raw := range result.Assets {
|
|
227
|
+
id, _ := assetIDFromRaw(raw)
|
|
228
|
+
node, err := decodeNodeAsset(raw)
|
|
229
|
+
if err != nil {
|
|
230
|
+
return mediaIdentityError(id, "/content", err.Error())
|
|
231
|
+
}
|
|
232
|
+
nodes[id] = node
|
|
233
|
+
kind, pippitID, mediaID, readErr := readNodeIdentity(node)
|
|
234
|
+
if readErr == nil {
|
|
235
|
+
previous[id] = identity{id, kind, pippitID, mediaID}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
for _, patch := range patches {
|
|
240
|
+
if !affected[patch.AssetID] || !touchesMediaIdentity(patch.Path) {
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
node, err := projectIdentityPatch(nodes[patch.AssetID], patch)
|
|
244
|
+
if err != nil {
|
|
245
|
+
return mediaIdentityError(patch.AssetID, patch.Path, err.Error())
|
|
246
|
+
}
|
|
247
|
+
nodes[patch.AssetID] = node
|
|
248
|
+
}
|
|
249
|
+
var identities []identity
|
|
250
|
+
var materialIDs []string
|
|
251
|
+
seen := make(map[string]bool)
|
|
252
|
+
for _, nodeID := range ordered {
|
|
253
|
+
kind, pippitID, mediaID, err := readNodeIdentity(nodes[nodeID])
|
|
254
|
+
if err != nil {
|
|
255
|
+
return mediaIdentityError(nodeID, "/content", err.Error())
|
|
256
|
+
}
|
|
257
|
+
if kind == "" || (pippitID == "" && mediaID == "") {
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
if old, ok := previous[nodeID]; ok && old.kind == kind && old.pippitID == pippitID && old.mediaID == mediaID {
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
if mediaID == nodeID || (mediaID != "" && mediaID == pippitID) {
|
|
264
|
+
return mediaIdentityError(nodeID, "/content/assetId", "expected cloud media ID, received Pippit material or Canvas node ID")
|
|
265
|
+
}
|
|
266
|
+
if pippitID == "" {
|
|
267
|
+
return mediaIdentityError(nodeID, "/content/pippitAssetId", "Pippit material ID is required to verify the media")
|
|
268
|
+
}
|
|
269
|
+
if mediaID == "" {
|
|
270
|
+
return mediaIdentityError(nodeID, "/content/assetId", "use a semantic media command to resolve pippitAssetId")
|
|
271
|
+
}
|
|
272
|
+
identities = append(identities, identity{nodeID, kind, pippitID, mediaID})
|
|
273
|
+
if !seen[pippitID] {
|
|
274
|
+
materialIDs = append(materialIDs, pippitID)
|
|
275
|
+
seen[pippitID] = true
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if len(materialIDs) == 0 {
|
|
279
|
+
return nil
|
|
280
|
+
}
|
|
281
|
+
materials, err := queryAssets(ctx, materialIDs, true, runner)
|
|
282
|
+
if err != nil {
|
|
283
|
+
return fmt.Errorf("canvas media identity preflight failed: %w", err)
|
|
284
|
+
}
|
|
285
|
+
byID := make(map[string]json.RawMessage)
|
|
286
|
+
for _, raw := range materials.Assets {
|
|
287
|
+
id, _ := assetIDFromRaw(raw)
|
|
288
|
+
byID[id] = raw
|
|
289
|
+
}
|
|
290
|
+
for _, item := range identities {
|
|
291
|
+
expected, err := cloudMediaID(byID[item.pippitID], item.kind)
|
|
292
|
+
if err != nil {
|
|
293
|
+
return mediaIdentityError(item.nodeID, "/content/assetId", err.Error())
|
|
294
|
+
}
|
|
295
|
+
if expected != item.mediaID {
|
|
296
|
+
return mediaIdentityError(item.nodeID, "/content/assetId", "media ID does not belong to the Pippit material")
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return nil
|
|
300
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
package canvas
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"os"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
"strings"
|
|
10
|
+
"testing"
|
|
11
|
+
|
|
12
|
+
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
const testPippitID = "10155947700492"
|
|
16
|
+
const testMediaID = "7693046454219440665"
|
|
17
|
+
const testNodeID = "10901596019212"
|
|
18
|
+
|
|
19
|
+
func imageNode(mediaID string) json.RawMessage {
|
|
20
|
+
return json.RawMessage(fmt.Sprintf(`{"type":"biz/image","pippitAssetId":"%s","content":{"pippitAssetId":"%s","assetId":"%s","caption":"keep"}}`, testNodeID, testPippitID, mediaID))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func applyIdentityFixture(patches []PatchEntry, mediaRaw string) (ApplyOptions, *common.Runner, *int) {
|
|
24
|
+
writes := 0
|
|
25
|
+
client := &fakeClient{send: func(_ context.Context, path string, body, out any) error {
|
|
26
|
+
switch path {
|
|
27
|
+
case QueryPath:
|
|
28
|
+
request := body.(queryRequest)
|
|
29
|
+
var assets []json.RawMessage
|
|
30
|
+
for _, id := range request.PippitAssetIDs {
|
|
31
|
+
if id == testNodeID {
|
|
32
|
+
content, _ := json.Marshal(string(imageNode(testMediaID)))
|
|
33
|
+
assets = append(assets, json.RawMessage(fmt.Sprintf(`{"PippitAssetID":"%s","TextInfo":{"content":%s},"version":5}`, id, content)))
|
|
34
|
+
} else if id == testPippitID {
|
|
35
|
+
assets = append(assets, json.RawMessage(mediaRaw))
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
payload, _ := json.Marshal(map[string]any{"ret": "0", "data": map[string]any{"Assets": assets}})
|
|
39
|
+
return decodeInto(out, string(payload))
|
|
40
|
+
case ApplyPath:
|
|
41
|
+
writes++
|
|
42
|
+
return decodeInto(out, fmt.Sprintf(`{"ret":"0","data":{"results":[{"transaction_id":"tx","status":"ack","asset_versions":{"%s":6}}]}}`, testNodeID))
|
|
43
|
+
default:
|
|
44
|
+
return fmt.Errorf("unexpected request %s", path)
|
|
45
|
+
}
|
|
46
|
+
}}
|
|
47
|
+
return ApplyOptions{Request: ApplyRequest{
|
|
48
|
+
BatchID: "batch", ClientID: "client", Transactions: []PatchTransaction{{TransactionID: "tx", Patches: patches}},
|
|
49
|
+
}}, runnerWithClient(client), &writes
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func imageMaterial() string {
|
|
53
|
+
return fmt.Sprintf(`{"PippitAssetID":"%s","AssetType":1,"SourceID":"workspace-source-id","Image":{"asset_id":"%s"}}`, testPippitID, testMediaID)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func TestApplyMediaIdentityRejectsBadIDsBeforeWrite(t *testing.T) {
|
|
57
|
+
for _, wrong := range []string{testPippitID, testNodeID, "workspace-source-id", "7693046454219440666"} {
|
|
58
|
+
t.Run(wrong, func(t *testing.T) {
|
|
59
|
+
opts, runner, writes := applyIdentityFixture([]PatchEntry{{AssetID: testNodeID, Op: "add", Path: "", Value: imageNode(wrong)}}, imageMaterial())
|
|
60
|
+
_, err := Apply(context.Background(), opts, runner)
|
|
61
|
+
if err == nil || !strings.Contains(err.Error(), "INVALID_MEDIA_IDENTITY") {
|
|
62
|
+
t.Fatalf("error = %v", err)
|
|
63
|
+
}
|
|
64
|
+
if *writes != 0 {
|
|
65
|
+
t.Fatalf("invalid data was written %d times", *writes)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func TestApplyMediaIdentityChecksFinalTransactionAndAllowsValidPair(t *testing.T) {
|
|
72
|
+
patches := []PatchEntry{
|
|
73
|
+
{AssetID: testNodeID, Op: "add", Path: "", Value: imageNode(testPippitID)},
|
|
74
|
+
{AssetID: testNodeID, Op: "replace", Path: "/content/assetId", Value: json.RawMessage(`"` + testMediaID + `"`)},
|
|
75
|
+
}
|
|
76
|
+
opts, runner, writes := applyIdentityFixture(patches, imageMaterial())
|
|
77
|
+
if _, err := Apply(context.Background(), opts, runner); err != nil {
|
|
78
|
+
t.Fatal(err)
|
|
79
|
+
}
|
|
80
|
+
if *writes != 1 {
|
|
81
|
+
t.Fatalf("writes = %d", *writes)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func TestApplyMediaIdentityRejectsPartialPatchBypass(t *testing.T) {
|
|
86
|
+
opts, runner, writes := applyIdentityFixture([]PatchEntry{{AssetID: testNodeID, Op: "replace", Path: "/content/assetId", Value: json.RawMessage(`"` + testPippitID + `"`)}}, imageMaterial())
|
|
87
|
+
if _, err := Apply(context.Background(), opts, runner); err == nil {
|
|
88
|
+
t.Fatal("expected identity error")
|
|
89
|
+
}
|
|
90
|
+
if *writes != 0 {
|
|
91
|
+
t.Fatal("partial invalid patch was written")
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func TestApplyMediaIdentityRejectsNumbersWrongTypeMissingOrUnreadyMaterial(t *testing.T) {
|
|
96
|
+
for name, raw := range map[string]string{
|
|
97
|
+
"numeric": fmt.Sprintf(`{"PippitAssetID":"%s","AssetType":1,"Image":{"asset_id":7693046454219440665}}`, testPippitID),
|
|
98
|
+
"wrong-type": fmt.Sprintf(`{"PippitAssetID":"%s","AssetType":2,"Image":{"asset_id":"%s"},"Video":{"asset_id":"video-id"}}`, testPippitID, testMediaID),
|
|
99
|
+
"unready": fmt.Sprintf(`{"PippitAssetID":"%s","AssetType":1,"Image":{}}`, testPippitID),
|
|
100
|
+
"missing": "null",
|
|
101
|
+
} {
|
|
102
|
+
t.Run(name, func(t *testing.T) {
|
|
103
|
+
opts, runner, writes := applyIdentityFixture([]PatchEntry{{AssetID: testNodeID, Op: "add", Path: "", Value: imageNode(testMediaID)}}, raw)
|
|
104
|
+
if _, err := Apply(context.Background(), opts, runner); err == nil {
|
|
105
|
+
t.Fatal("expected identity preflight error")
|
|
106
|
+
}
|
|
107
|
+
if *writes != 0 {
|
|
108
|
+
t.Fatal("invalid material was written")
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func TestMediaUploadLocatorDistinguishesSourceAndTypedMedia(t *testing.T) {
|
|
115
|
+
for _, kind := range []string{"image", "video", "audio"} {
|
|
116
|
+
nativeType := map[string]int{"image": 1, "video": 2, "audio": 3}[kind]
|
|
117
|
+
raw := json.RawMessage(fmt.Sprintf(`{"PippitAssetID":"%s","AssetType":%d,"asset_id":"wrong-root-id","SourceID":"workspace-source-id","%s":{"asset_id":"%s"}}`, testPippitID, nativeType, strings.ToUpper(kind[:1])+kind[1:], testMediaID))
|
|
118
|
+
locator := locatorFromAsset(raw, testPippitID)
|
|
119
|
+
if locator.PippitAssetID != testPippitID || locator.MediaAssetID != testMediaID || locator.SourceID != "workspace-source-id" {
|
|
120
|
+
t.Fatalf("locator = %#v", locator)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func TestUploadExposesDistinctIdentifiersAndKeepsUnreadyMediaProcessing(t *testing.T) {
|
|
126
|
+
for _, ready := range []bool{true, false} {
|
|
127
|
+
t.Run(fmt.Sprint(ready), func(t *testing.T) {
|
|
128
|
+
file := filepath.Join(t.TempDir(), "reference.png")
|
|
129
|
+
if err := os.WriteFile(file, []byte("image"), 0600); err != nil {
|
|
130
|
+
t.Fatal(err)
|
|
131
|
+
}
|
|
132
|
+
raw := imageMaterial()
|
|
133
|
+
if !ready {
|
|
134
|
+
raw = fmt.Sprintf(`{"PippitAssetID":"%s","AssetType":1,"Image":{}}`, testPippitID)
|
|
135
|
+
}
|
|
136
|
+
client := &fakeClient{
|
|
137
|
+
multipart: func(_ context.Context, _ string, _ map[string]string, _ common.MultipartFile, out any) error {
|
|
138
|
+
return decodeInto(out, fmt.Sprintf(`{"ret":"0","data":{"asset_id":"workspace-source-id","pippit_asset_id":"%s"}}`, testPippitID))
|
|
139
|
+
},
|
|
140
|
+
send: func(_ context.Context, _ string, _ any, out any) error {
|
|
141
|
+
return decodeInto(out, `{"ret":"0","data":{"Assets":[`+raw+`]}}`)
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
result, err := Upload(context.Background(), UploadOptions{Path: file}, runnerWithClient(client))
|
|
145
|
+
if err != nil {
|
|
146
|
+
t.Fatal(err)
|
|
147
|
+
}
|
|
148
|
+
if result.PippitAssetID != testPippitID || result.SourceID != "workspace-source-id" || result.AssetID != result.SourceID {
|
|
149
|
+
t.Fatalf("result = %#v", result)
|
|
150
|
+
}
|
|
151
|
+
if ready {
|
|
152
|
+
if result.MediaAssetID != testMediaID || result.Locator.MediaAssetID != testMediaID || result.State != StateReady {
|
|
153
|
+
t.Fatalf("ready result = %#v", result)
|
|
154
|
+
}
|
|
155
|
+
} else if result.MediaAssetID != "" || result.State != StateProcessing || result.Warning == "" {
|
|
156
|
+
t.Fatalf("processing result = %#v", result)
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -38,6 +38,7 @@ type UploadOptions struct {
|
|
|
38
38
|
// Canvas patches; callers should persist PippitAssetID instead.
|
|
39
39
|
type AssetLocator struct {
|
|
40
40
|
PippitAssetID string `json:"pippit_asset_id"`
|
|
41
|
+
MediaAssetID string `json:"media_asset_id,omitempty"`
|
|
41
42
|
SourceID string `json:"source_id,omitempty"`
|
|
42
43
|
DownloadURL string `json:"download_url,omitempty"`
|
|
43
44
|
InternalURL string `json:"internal_url,omitempty"`
|
|
@@ -46,8 +47,11 @@ type AssetLocator struct {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
type UploadResult struct {
|
|
49
|
-
State
|
|
50
|
+
State string `json:"state"`
|
|
51
|
+
// Deprecated compatibility field: upload workspace/source ID, never cloud media ID.
|
|
50
52
|
AssetID string `json:"asset_id,omitempty"`
|
|
53
|
+
SourceID string `json:"source_id,omitempty"`
|
|
54
|
+
MediaAssetID string `json:"media_asset_id,omitempty"`
|
|
51
55
|
PippitAssetID string `json:"pippit_asset_id"`
|
|
52
56
|
Locator AssetLocator `json:"locator"`
|
|
53
57
|
Asset json.RawMessage `json:"asset,omitempty"`
|
|
@@ -105,6 +109,7 @@ func Upload(ctx context.Context, opts UploadOptions, runner *common.Runner) (*Up
|
|
|
105
109
|
result := &UploadResult{
|
|
106
110
|
State: StateProcessing,
|
|
107
111
|
AssetID: assetID,
|
|
112
|
+
SourceID: assetID,
|
|
108
113
|
PippitAssetID: pippitAssetID,
|
|
109
114
|
Locator: AssetLocator{PippitAssetID: pippitAssetID},
|
|
110
115
|
LogID: strings.TrimSpace(envelope.LogID),
|
|
@@ -121,8 +126,13 @@ func Upload(ctx context.Context, opts UploadOptions, runner *common.Runner) (*Up
|
|
|
121
126
|
}
|
|
122
127
|
result.State = StateReady
|
|
123
128
|
result.Locator = locatorFromAsset(asset, pippitAssetID)
|
|
129
|
+
result.MediaAssetID = result.Locator.MediaAssetID
|
|
124
130
|
result.Asset = asset
|
|
125
131
|
result.QueryLogID = queryLogID
|
|
132
|
+
if uploadedMediaKind(asset) != "" && result.MediaAssetID == "" {
|
|
133
|
+
result.State = StateProcessing
|
|
134
|
+
result.Warning = "material is queryable but its cloud media ID is not ready; use a semantic media command or query again, do not substitute asset_id/source_id"
|
|
135
|
+
}
|
|
126
136
|
return result, nil
|
|
127
137
|
}
|
|
128
138
|
|
|
@@ -200,11 +210,30 @@ func uploadWaitError(err error, assetID string, timeout time.Duration) error {
|
|
|
200
210
|
return fmt.Errorf("wait for uploaded canvas asset %q canceled: %w", assetID, err)
|
|
201
211
|
}
|
|
202
212
|
|
|
213
|
+
func uploadedMediaKind(raw json.RawMessage) string {
|
|
214
|
+
var record map[string]json.RawMessage
|
|
215
|
+
_ = json.Unmarshal(raw, &record)
|
|
216
|
+
var nativeType int
|
|
217
|
+
_ = json.Unmarshal(record["AssetType"], &nativeType)
|
|
218
|
+
for kind, value := range canvasMediaAssetTypes {
|
|
219
|
+
if nativeType == value {
|
|
220
|
+
return kind
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return ""
|
|
224
|
+
}
|
|
225
|
+
|
|
203
226
|
func locatorFromAsset(raw json.RawMessage, pippitAssetID string) AssetLocator {
|
|
204
227
|
var value any
|
|
205
228
|
_ = json.Unmarshal(raw, &value)
|
|
229
|
+
kind := uploadedMediaKind(raw)
|
|
230
|
+
mediaID := ""
|
|
231
|
+
if kind != "" {
|
|
232
|
+
mediaID, _ = cloudMediaID(raw, kind)
|
|
233
|
+
}
|
|
206
234
|
return AssetLocator{
|
|
207
235
|
PippitAssetID: pippitAssetID,
|
|
236
|
+
MediaAssetID: mediaID,
|
|
208
237
|
SourceID: findFirstString(value, "SourceID", "source_id", "sourceId"),
|
|
209
238
|
DownloadURL: findFirstString(value, "DownloadUrl", "DownloadURL", "download_url", "downloadUrl"),
|
|
210
239
|
InternalURL: findFirstString(value, "InternalUrl", "InternalURL", "internal_url", "internalUrl"),
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
package common
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"net/http"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
"github.com/Pippit-dev/pippit-cli/internal/adapter"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
type decisionPolicy struct {
|
|
14
|
+
adapter.Default
|
|
15
|
+
decision adapter.AuthDecision
|
|
16
|
+
err error
|
|
17
|
+
prepared, stripped int
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func (p *decisionPolicy) PrepareAPIRequest(context.Context, *http.Request) (adapter.AuthDecision, error) {
|
|
21
|
+
p.prepared++
|
|
22
|
+
return p.decision, p.err
|
|
23
|
+
}
|
|
24
|
+
func (p *decisionPolicy) StripSensitiveHeaders(req *http.Request) {
|
|
25
|
+
p.stripped++
|
|
26
|
+
req.Header.Del("X-Adapter-Auth")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The common client handles authentication decisions and origin checks. Adapter errors must not trigger a fallback to local authentication.
|
|
30
|
+
func TestAdapterAuthenticationBoundary(t *testing.T) {
|
|
31
|
+
failed := errors.New("adapter rejected")
|
|
32
|
+
for _, tc := range []struct {
|
|
33
|
+
name, target string
|
|
34
|
+
decision adapter.AuthDecision
|
|
35
|
+
policyErr error
|
|
36
|
+
wantAuth, wantPrepare, wantStrip bool
|
|
37
|
+
wantError bool
|
|
38
|
+
}{
|
|
39
|
+
{name: "default", target: "https://api.example.test/path", wantAuth: true, wantPrepare: true},
|
|
40
|
+
{name: "provided", target: "https://api.example.test/path", decision: adapter.AuthProvided, wantPrepare: true},
|
|
41
|
+
{name: "error", target: "https://api.example.test/path", policyErr: failed, wantPrepare: true, wantError: true},
|
|
42
|
+
{name: "invalid", target: "https://api.example.test/path", decision: adapter.AuthDecision(99), wantPrepare: true, wantError: true},
|
|
43
|
+
{name: "external", target: "https://files.example.test/file", wantStrip: true},
|
|
44
|
+
{name: "different port", target: "https://api.example.test:8443/file", wantStrip: true},
|
|
45
|
+
{name: "different scheme", target: "http://api.example.test/file", wantStrip: true},
|
|
46
|
+
} {
|
|
47
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
48
|
+
policy := &decisionPolicy{decision: tc.decision, err: tc.policyErr}
|
|
49
|
+
authCalls := 0
|
|
50
|
+
authorizer := NewAccessKeyContextProviderAuthorizer(func(context.Context) (string, error) { authCalls++; return "local-key", nil })
|
|
51
|
+
client := NewHTTPClientWithAdapter("https://api.example.test", time.Second, authorizer, policy).(*httpClient)
|
|
52
|
+
req, err := http.NewRequest(http.MethodGet, tc.target, nil)
|
|
53
|
+
if err != nil {
|
|
54
|
+
t.Fatal(err)
|
|
55
|
+
}
|
|
56
|
+
err = client.prepareRequest(context.Background(), req, map[string]string{"Authorization": "Bearer caller", "X-Adapter-Auth": "caller"})
|
|
57
|
+
if (err != nil) != tc.wantError {
|
|
58
|
+
t.Fatalf("error=%v", err)
|
|
59
|
+
}
|
|
60
|
+
if tc.policyErr != nil && !errors.Is(err, failed) {
|
|
61
|
+
t.Fatalf("adapter error lost: %v", err)
|
|
62
|
+
}
|
|
63
|
+
if (authCalls == 1) != tc.wantAuth || (policy.prepared == 1) != tc.wantPrepare || (policy.stripped == 1) != tc.wantStrip {
|
|
64
|
+
t.Fatalf("auth=%d prepare=%d strip=%d", authCalls, policy.prepared, policy.stripped)
|
|
65
|
+
}
|
|
66
|
+
if !tc.wantAuth && req.Header.Get("Authorization") != "" {
|
|
67
|
+
t.Fatal("caller Authorization leaked")
|
|
68
|
+
}
|
|
69
|
+
if tc.wantStrip && req.Header.Get("X-Adapter-Auth") != "" {
|
|
70
|
+
t.Fatal("external request retained adapter credentials")
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}
|